@fluojs/cli 2.0.1 → 3.0.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 (118) hide show
  1. package/README.ko.md +169 -21
  2. package/README.md +172 -21
  3. package/dist/cli.d.ts +5 -4
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +127 -35
  6. package/dist/commands/diagnostics.d.ts +35 -0
  7. package/dist/commands/diagnostics.d.ts.map +1 -1
  8. package/dist/commands/diagnostics.js +60 -0
  9. package/dist/commands/inspect.d.ts +5 -1
  10. package/dist/commands/inspect.d.ts.map +1 -1
  11. package/dist/commands/inspect.js +84 -17
  12. package/dist/commands/migrate.d.ts.map +1 -1
  13. package/dist/commands/migrate.js +27 -15
  14. package/dist/commands/migration-transform-tokens.d.ts +15 -0
  15. package/dist/commands/migration-transform-tokens.d.ts.map +1 -0
  16. package/dist/commands/migration-transform-tokens.js +41 -0
  17. package/dist/commands/new.d.ts.map +1 -1
  18. package/dist/commands/new.js +29 -9
  19. package/dist/commands/output-path-safety.d.ts +14 -0
  20. package/dist/commands/output-path-safety.d.ts.map +1 -0
  21. package/dist/commands/output-path-safety.js +34 -0
  22. package/dist/commands/scripts.js +2 -2
  23. package/dist/commands/typegen-artifact.d.ts +54 -0
  24. package/dist/commands/typegen-artifact.d.ts.map +1 -0
  25. package/dist/commands/typegen-artifact.js +117 -0
  26. package/dist/commands/typegen-generation-child.d.ts +2 -0
  27. package/dist/commands/typegen-generation-child.d.ts.map +1 -0
  28. package/dist/commands/typegen-generation-child.js +59 -0
  29. package/dist/commands/typegen-generation-process.d.ts +46 -0
  30. package/dist/commands/typegen-generation-process.d.ts.map +1 -0
  31. package/dist/commands/typegen-generation-process.js +131 -0
  32. package/dist/commands/typegen-generation-protocol.d.ts +16 -0
  33. package/dist/commands/typegen-generation-protocol.d.ts.map +1 -0
  34. package/dist/commands/typegen-generation-protocol.js +35 -0
  35. package/dist/commands/typegen-isolated-source.d.ts +10 -0
  36. package/dist/commands/typegen-isolated-source.d.ts.map +1 -0
  37. package/dist/commands/typegen-isolated-source.js +75 -0
  38. package/dist/commands/typegen-options.d.ts +20 -0
  39. package/dist/commands/typegen-options.d.ts.map +1 -0
  40. package/dist/commands/typegen-options.js +71 -0
  41. package/dist/commands/typegen-source.d.ts +59 -0
  42. package/dist/commands/typegen-source.d.ts.map +1 -0
  43. package/dist/commands/typegen-source.js +183 -0
  44. package/dist/commands/typegen-watch.d.ts +46 -0
  45. package/dist/commands/typegen-watch.d.ts.map +1 -0
  46. package/dist/commands/typegen-watch.js +212 -0
  47. package/dist/commands/typegen.d.ts +29 -0
  48. package/dist/commands/typegen.d.ts.map +1 -0
  49. package/dist/commands/typegen.js +119 -0
  50. package/dist/dev-runner/node-restart-runner.d.ts.map +1 -1
  51. package/dist/dev-runner/node-restart-runner.js +54 -7
  52. package/dist/fixtures/inspect-react-app.module.d.ts +4 -0
  53. package/dist/fixtures/inspect-react-app.module.d.ts.map +1 -0
  54. package/dist/fixtures/inspect-react-app.module.js +32 -0
  55. package/dist/fixtures/typegen-react-app.module.d.ts +4 -0
  56. package/dist/fixtures/typegen-react-app.module.d.ts.map +1 -0
  57. package/dist/fixtures/typegen-react-app.module.js +33 -0
  58. package/dist/index.d.ts +6 -5
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +3 -2
  61. package/dist/new/install.js +1 -1
  62. package/dist/new/package-spec-resolver.d.ts.map +1 -1
  63. package/dist/new/package-spec-resolver.js +50 -59
  64. package/dist/new/prompt.d.ts +1 -0
  65. package/dist/new/prompt.d.ts.map +1 -1
  66. package/dist/new/prompt.js +40 -12
  67. package/dist/new/published-internal-dependencies.d.ts +20 -0
  68. package/dist/new/published-internal-dependencies.d.ts.map +1 -0
  69. package/dist/new/published-internal-dependencies.js +20 -0
  70. package/dist/new/react-vite-ssr-scaffold.d.ts +19 -0
  71. package/dist/new/react-vite-ssr-scaffold.d.ts.map +1 -0
  72. package/dist/new/react-vite-ssr-scaffold.js +108 -0
  73. package/dist/new/resolver.d.ts +2 -2
  74. package/dist/new/resolver.d.ts.map +1 -1
  75. package/dist/new/resolver.js +5 -1
  76. package/dist/new/scaffold.d.ts.map +1 -1
  77. package/dist/new/scaffold.js +62 -31
  78. package/dist/new/starter-profiles.d.ts +8 -4
  79. package/dist/new/starter-profiles.d.ts.map +1 -1
  80. package/dist/new/starter-profiles.js +52 -18
  81. package/dist/new/templates/react-vite-ssr/README.md.ejs +74 -0
  82. package/dist/new/templates/react-vite-ssr/playwright.config.ts.ejs +26 -0
  83. package/dist/new/templates/react-vite-ssr/src/app.test.ts.ejs +82 -0
  84. package/dist/new/templates/react-vite-ssr/src/app.ts.ejs +91 -0
  85. package/dist/new/templates/react-vite-ssr/src/entry-client.tsx.ejs +29 -0
  86. package/dist/new/templates/react-vite-ssr/src/entry-server.tsx.ejs +52 -0
  87. package/dist/new/templates/react-vite-ssr/src/load-manifest.test.ts.ejs +55 -0
  88. package/dist/new/templates/react-vite-ssr/src/load-manifest.ts.ejs +53 -0
  89. package/dist/new/templates/react-vite-ssr/src/main.ts.ejs +18 -0
  90. package/dist/new/templates/react-vite-ssr/src/page.tsx.ejs +43 -0
  91. package/dist/new/templates/react-vite-ssr/src/react-app.test.tsx.ejs +67 -0
  92. package/dist/new/templates/react-vite-ssr/src/react-app.tsx.ejs +46 -0
  93. package/dist/new/templates/react-vite-ssr/src/styles.css.ejs +25 -0
  94. package/dist/new/templates/react-vite-ssr/src/styles.d.ts.ejs +1 -0
  95. package/dist/new/templates/react-vite-ssr/tests/production-hydration.spec.ts.ejs +54 -0
  96. package/dist/new/templates/react-vite-ssr/tsconfig.json.ejs +16 -0
  97. package/dist/new/templates/react-vite-ssr/vite.client.config.ts.ejs +24 -0
  98. package/dist/new/templates/react-vite-ssr/vite.server.config.ts.ejs +17 -0
  99. package/dist/new/templates/react-vite-ssr/vitest.config.ts.ejs +10 -0
  100. package/dist/new/types.d.ts +4 -0
  101. package/dist/new/types.d.ts.map +1 -1
  102. package/dist/public-typegen.d.ts +14 -0
  103. package/dist/public-typegen.d.ts.map +1 -0
  104. package/dist/public-typegen.js +17 -0
  105. package/dist/run-cli.d.ts +2 -1
  106. package/dist/run-cli.d.ts.map +1 -1
  107. package/dist/studio/sidecar.d.ts.map +1 -1
  108. package/dist/studio/sidecar.js +35 -9
  109. package/dist/transforms/nestjs-migrate.d.ts +4 -1
  110. package/dist/transforms/nestjs-migrate.d.ts.map +1 -1
  111. package/dist/transforms/nestjs-migrate.js +625 -65
  112. package/dist/typegen-contract.d.ts +10 -0
  113. package/dist/typegen-contract.d.ts.map +1 -0
  114. package/dist/typegen-contract.js +9 -0
  115. package/dist/usage.d.ts +6 -0
  116. package/dist/usage.d.ts.map +1 -1
  117. package/dist/usage.js +47 -0
  118. package/package.json +19 -10
@@ -51,7 +51,8 @@ export const STARTER_PROFILE_REGISTRY = [{
51
51
  id: 'application-bun-bun-http',
52
52
  platformPromptLabel: 'Bun native HTTP',
53
53
  promptLabel: 'Application (HTTP starter)',
54
- schema: createSchema('application', 'bun', 'bun', 'http')
54
+ schema: createSchema('application', 'bun', 'bun', 'http'),
55
+ starter: 'standard'
55
56
  }, {
56
57
  dependencies: {
57
58
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/validation', '@fluojs/di', '@fluojs/http', '@fluojs/platform-deno', '@fluojs/runtime'],
@@ -67,7 +68,8 @@ export const STARTER_PROFILE_REGISTRY = [{
67
68
  id: 'application-deno-deno-http',
68
69
  platformPromptLabel: 'Deno native HTTP',
69
70
  promptLabel: 'Application (HTTP starter)',
70
- schema: createSchema('application', 'deno', 'deno', 'http')
71
+ schema: createSchema('application', 'deno', 'deno', 'http'),
72
+ starter: 'standard'
71
73
  }, {
72
74
  dependencies: {
73
75
  dependencies: ['@fluojs/core', '@fluojs/validation', '@fluojs/di', '@fluojs/http', '@fluojs/platform-cloudflare-workers', '@fluojs/runtime'],
@@ -83,7 +85,8 @@ export const STARTER_PROFILE_REGISTRY = [{
83
85
  id: 'application-cloudflare-workers-cloudflare-workers-http',
84
86
  platformPromptLabel: 'Cloudflare Workers',
85
87
  promptLabel: 'Application (HTTP starter)',
86
- schema: createSchema('application', 'cloudflare-workers', 'cloudflare-workers', 'http')
88
+ schema: createSchema('application', 'cloudflare-workers', 'cloudflare-workers', 'http'),
89
+ starter: 'standard'
87
90
  }, {
88
91
  dependencies: {
89
92
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/validation', '@fluojs/di', '@fluojs/http', '@fluojs/platform-fastify', '@fluojs/runtime'],
@@ -99,7 +102,25 @@ export const STARTER_PROFILE_REGISTRY = [{
99
102
  id: 'application-node-fastify-http',
100
103
  platformPromptLabel: 'Fastify',
101
104
  promptLabel: 'Application (HTTP starter)',
102
- schema: createSchema('application', 'node', 'fastify', 'http')
105
+ schema: createSchema('application', 'node', 'fastify', 'http'),
106
+ starter: 'standard'
107
+ }, {
108
+ dependencies: {
109
+ dependencies: ['@fluojs/core', '@fluojs/http', '@fluojs/platform-fastify', '@fluojs/react', '@fluojs/runtime', '@fluojs/validation', 'react', 'react-dom'],
110
+ devDependencies: ['@fluojs/cli', '@fluojs/testing', '@fluojs/vite', '@playwright/test', '@types/react', '@types/react-dom', 'happy-dom']
111
+ },
112
+ emitter: {
113
+ platform: 'fastify',
114
+ preset: 'standard',
115
+ runtime: 'node',
116
+ transport: 'http',
117
+ type: 'react-vite-ssr'
118
+ },
119
+ id: 'application-node-fastify-react-vite-ssr',
120
+ platformPromptLabel: 'Fastify',
121
+ promptLabel: 'React SSR + Vite',
122
+ schema: createSchema('application', 'node', 'fastify', 'http'),
123
+ starter: 'react-vite-ssr'
103
124
  }, {
104
125
  dependencies: {
105
126
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/validation', '@fluojs/di', '@fluojs/http', '@fluojs/platform-express', '@fluojs/runtime'],
@@ -115,7 +136,8 @@ export const STARTER_PROFILE_REGISTRY = [{
115
136
  id: 'application-node-express-http',
116
137
  platformPromptLabel: 'Express',
117
138
  promptLabel: 'Application (HTTP starter)',
118
- schema: createSchema('application', 'node', 'express', 'http')
139
+ schema: createSchema('application', 'node', 'express', 'http'),
140
+ starter: 'standard'
119
141
  }, {
120
142
  dependencies: {
121
143
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/validation', '@fluojs/di', '@fluojs/http', '@fluojs/platform-nodejs', '@fluojs/runtime'],
@@ -131,7 +153,8 @@ export const STARTER_PROFILE_REGISTRY = [{
131
153
  id: 'application-node-nodejs-http',
132
154
  platformPromptLabel: 'Raw Node.js HTTP',
133
155
  promptLabel: 'Application (HTTP starter)',
134
- schema: createSchema('application', 'node', 'nodejs', 'http')
156
+ schema: createSchema('application', 'node', 'nodejs', 'http'),
157
+ starter: 'standard'
135
158
  }, {
136
159
  dependencies: {
137
160
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/di', '@fluojs/microservices', '@fluojs/runtime'],
@@ -146,7 +169,8 @@ export const STARTER_PROFILE_REGISTRY = [{
146
169
  },
147
170
  id: 'microservice-node-none-tcp',
148
171
  promptLabel: 'Microservice (transport-first starter)',
149
- schema: createSchema('microservice', 'node', 'none', 'tcp')
172
+ schema: createSchema('microservice', 'node', 'none', 'tcp'),
173
+ starter: 'standard'
150
174
  }, {
151
175
  dependencies: {
152
176
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/di', '@fluojs/microservices', '@fluojs/runtime', 'ioredis'],
@@ -161,7 +185,8 @@ export const STARTER_PROFILE_REGISTRY = [{
161
185
  },
162
186
  id: 'microservice-node-none-redis-streams',
163
187
  promptLabel: 'Microservice (Redis Streams starter)',
164
- schema: createSchema('microservice', 'node', 'none', 'redis-streams')
188
+ schema: createSchema('microservice', 'node', 'none', 'redis-streams'),
189
+ starter: 'standard'
165
190
  }, {
166
191
  dependencies: {
167
192
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/di', '@fluojs/microservices', '@fluojs/runtime', 'mqtt'],
@@ -176,7 +201,8 @@ export const STARTER_PROFILE_REGISTRY = [{
176
201
  },
177
202
  id: 'microservice-node-none-mqtt',
178
203
  promptLabel: 'Microservice (MQTT starter)',
179
- schema: createSchema('microservice', 'node', 'none', 'mqtt')
204
+ schema: createSchema('microservice', 'node', 'none', 'mqtt'),
205
+ starter: 'standard'
180
206
  }, {
181
207
  dependencies: {
182
208
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/di', '@fluojs/microservices', '@fluojs/runtime', '@grpc/grpc-js', '@grpc/proto-loader'],
@@ -191,7 +217,8 @@ export const STARTER_PROFILE_REGISTRY = [{
191
217
  },
192
218
  id: 'microservice-node-none-grpc',
193
219
  promptLabel: 'Microservice (gRPC starter)',
194
- schema: createSchema('microservice', 'node', 'none', 'grpc')
220
+ schema: createSchema('microservice', 'node', 'none', 'grpc'),
221
+ starter: 'standard'
195
222
  }, {
196
223
  dependencies: {
197
224
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/di', '@fluojs/microservices', '@fluojs/runtime', 'nats'],
@@ -206,7 +233,8 @@ export const STARTER_PROFILE_REGISTRY = [{
206
233
  },
207
234
  id: 'microservice-node-none-nats',
208
235
  promptLabel: 'Microservice (NATS starter)',
209
- schema: createSchema('microservice', 'node', 'none', 'nats')
236
+ schema: createSchema('microservice', 'node', 'none', 'nats'),
237
+ starter: 'standard'
210
238
  }, {
211
239
  dependencies: {
212
240
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/di', '@fluojs/microservices', '@fluojs/runtime', 'kafkajs'],
@@ -221,7 +249,8 @@ export const STARTER_PROFILE_REGISTRY = [{
221
249
  },
222
250
  id: 'microservice-node-none-kafka',
223
251
  promptLabel: 'Microservice (Kafka starter)',
224
- schema: createSchema('microservice', 'node', 'none', 'kafka')
252
+ schema: createSchema('microservice', 'node', 'none', 'kafka'),
253
+ starter: 'standard'
225
254
  }, {
226
255
  dependencies: {
227
256
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/di', '@fluojs/microservices', '@fluojs/runtime', 'amqplib'],
@@ -236,7 +265,8 @@ export const STARTER_PROFILE_REGISTRY = [{
236
265
  },
237
266
  id: 'microservice-node-none-rabbitmq',
238
267
  promptLabel: 'Microservice (RabbitMQ starter)',
239
- schema: createSchema('microservice', 'node', 'none', 'rabbitmq')
268
+ schema: createSchema('microservice', 'node', 'none', 'rabbitmq'),
269
+ starter: 'standard'
240
270
  }, {
241
271
  dependencies: {
242
272
  dependencies: ['@fluojs/config', '@fluojs/core', '@fluojs/validation', '@fluojs/di', '@fluojs/http', '@fluojs/microservices', '@fluojs/platform-fastify', '@fluojs/runtime'],
@@ -251,7 +281,8 @@ export const STARTER_PROFILE_REGISTRY = [{
251
281
  },
252
282
  id: 'mixed-node-fastify-tcp',
253
283
  promptLabel: 'Mixed (HTTP API + microservice starter)',
254
- schema: createSchema('mixed', 'node', 'fastify', 'tcp')
284
+ schema: createSchema('mixed', 'node', 'fastify', 'tcp'),
285
+ starter: 'standard'
255
286
  }];
256
287
 
257
288
  /** Supported scaffold shapes accepted by the current starter matrix. */
@@ -266,6 +297,8 @@ export const SUPPORTED_BOOTSTRAP_TRANSPORTS = ['http', ...SUPPORTED_MICROSERVICE
266
297
  export const SUPPORTED_BOOTSTRAP_TOOLING_PRESETS = ['standard'];
267
298
  /** Supported topology modes accepted by the current starter matrix. */
268
299
  export const SUPPORTED_BOOTSTRAP_TOPOLOGY_MODES = ['single-package'];
300
+ /** Named starter variants accepted by `fluo new`. */
301
+ export const SUPPORTED_BOOTSTRAP_STARTERS = ['standard', 'react-vite-ssr'];
269
302
 
270
303
  /** Default starter profile used for shape-less `fluo new` invocations. */
271
304
  export const DEFAULT_BOOTSTRAP_PROFILE = STARTER_PROFILE_REGISTRY.find(profile => profile.id === 'application-node-fastify-http');
@@ -279,7 +312,7 @@ export const DEFAULT_BOOTSTRAP_PROFILE = STARTER_PROFILE_REGISTRY.find(profile =
279
312
  */
280
313
  export function getStarterProfileForShape(shape, runtime) {
281
314
  if (runtime !== undefined) {
282
- return STARTER_PROFILE_REGISTRY.find(profile => profile.schema.shape === shape && profile.schema.runtime === runtime) ?? DEFAULT_BOOTSTRAP_PROFILE;
315
+ return STARTER_PROFILE_REGISTRY.find(profile => profile.starter === 'standard' && profile.schema.shape === shape && profile.schema.runtime === runtime) ?? DEFAULT_BOOTSTRAP_PROFILE;
283
316
  }
284
317
  if (shape === 'application') {
285
318
  return DEFAULT_BOOTSTRAP_PROFILE;
@@ -300,7 +333,7 @@ export function getStarterProfileForShape(shape, runtime) {
300
333
  * @returns Application starter profiles for the selected runtime, or all application starters when omitted.
301
334
  */
302
335
  export function getApplicationStarterProfiles(runtime) {
303
- return STARTER_PROFILE_REGISTRY.filter(profile => profile.schema.shape === 'application' && (runtime === undefined || profile.schema.runtime === runtime));
336
+ return STARTER_PROFILE_REGISTRY.filter(profile => profile.starter === 'standard' && profile.schema.shape === 'application' && (runtime === undefined || profile.schema.runtime === runtime));
304
337
  }
305
338
 
306
339
  /**
@@ -327,10 +360,11 @@ export function getDefaultBootstrapSchema() {
327
360
  * Finds the starter profile that exactly matches a resolved bootstrap schema.
328
361
  *
329
362
  * @param schema Fully resolved bootstrap schema.
363
+ * @param starter Named starter variant selected by the caller.
330
364
  * @returns The matching starter profile, or `undefined` when the schema is validation-only.
331
365
  */
332
- export function getStarterProfileFromSchema(schema) {
333
- return STARTER_PROFILE_REGISTRY.find(profile => profile.schema.shape === schema.shape && profile.schema.runtime === schema.runtime && profile.schema.platform === schema.platform && profile.schema.transport === schema.transport && profile.schema.tooling === schema.tooling && profile.schema.topology.deferred === schema.topology.deferred && profile.schema.topology.mode === schema.topology.mode);
366
+ export function getStarterProfileFromSchema(schema, starter = 'standard') {
367
+ return STARTER_PROFILE_REGISTRY.find(profile => profile.starter === starter && profile.schema.shape === schema.shape && profile.schema.runtime === schema.runtime && profile.schema.platform === schema.platform && profile.schema.transport === schema.transport && profile.schema.tooling === schema.tooling && profile.schema.topology.deferred === schema.topology.deferred && profile.schema.topology.mode === schema.topology.mode);
334
368
  }
335
369
 
336
370
  /**
@@ -0,0 +1,74 @@
1
+ # <%= projectName %>
2
+
3
+ HTTP-first React SSR starter generated by `fluo new --starter react-vite-ssr`.
4
+
5
+ Requires Node.js `>=24.0.0 <27`. Use Node 24 LTS for local development, CI, and container images. The server build targets `node24` and uses `@types/node@^24.0.0`.
6
+
7
+ ## First page
8
+
9
+ ```sh
10
+ <%= installCommand %>
11
+ <%= devCommand %>
12
+ ```
13
+
14
+ Open `http://127.0.0.1:3000/products/sku-42?preview=true`, then edit
15
+ `src/page.tsx`. The page component contains only page UI and hydrated interaction; the generated
16
+ application wiring keeps the server/client render tree aligned for you.
17
+
18
+ The decorated route remains explicit in `src/app.ts`:
19
+
20
+ ```ts
21
+ @Router('/products')
22
+ class ProductPageRouter {
23
+ @Path('/:sku')
24
+ @RequestDto(ProductPageRequest)
25
+ show(_input: ProductPageRequest): ReactElement {
26
+ return createElement(ProductPage);
27
+ }
28
+ }
29
+ ```
30
+
31
+ This handler still runs through `@fluojs/http` matching, DTO binding, validation, middleware,
32
+ guards, interceptors, request scopes, and not-found behavior before the application renderer sees
33
+ the returned `ReactElement`. Decorator-bearing application declarations stay in `.ts` files so the
34
+ Vite transform handles them; JSX rendering stays in `.tsx` modules such as `src/page.tsx`.
35
+
36
+ ## Production verification
37
+
38
+ ```sh
39
+ <%= typecheckCommand %>
40
+ <%= testCommand %>
41
+ <%= buildCommand %>
42
+ <%= startCommand %>
43
+ <%= testBrowserCommand %>
44
+ ```
45
+
46
+ `<%= testBrowserCommand %>` starts the built Fastify server and verifies the first HTTP response, emitted
47
+ assets, hydration, interaction, and full-document navigation without console warnings or errors.
48
+ Install the Playwright Chrome browser first when your environment does not already provide it.
49
+
50
+ ## Application-owned wiring
51
+
52
+ Read these files only when you need to change the default composition:
53
+
54
+ - `src/app.ts` owns explicit HTTP routes and registers the application renderer.
55
+ - `src/entry-server.tsx` is the application-owned `ReactPageRenderer`. It turns the already-loaded
56
+ Vite manifest into hydration assets and returns `ReactServerEntry` through
57
+ `createReactServerEntry(...)`. Replace this renderer when the application needs another document
58
+ or explicit asset policy.
59
+ - `src/react-app.tsx` is the shared server/client document. It owns
60
+ `ReactClientRouterProvider`, the request route snapshot, and stylesheet placement.
61
+ - `src/entry-client.tsx` hydrates that same document and reports a mismatch with the exact entry and
62
+ shared-composition files to compare.
63
+ - `src/main.ts` loads `dist/client/.vite/manifest.json` through `src/load-manifest.ts`, then boots the
64
+ normal Fastify HTTP application. Missing or malformed build output points back to `<%= buildCommand %>` and
65
+ `vite.client.config.ts`.
66
+
67
+ `@fluojs/react/vite` only parses the manifest value loaded by the application. It does not discover
68
+ files, run Vite, serve assets, or move route ownership out of `@fluojs/http`. The Fastify asset
69
+ controller is a minimal local starter boundary; production deployments should serve `dist/client`
70
+ through their normal static-file or CDN infrastructure.
71
+
72
+ `Link` remains a real anchor and `router.push(...)` performs full-document navigation through the
73
+ server-owned route. This starter intentionally excludes RSC, Server Functions, file routing, a
74
+ client route table, SPA document swapping, prefetch, and a data cache.
@@ -0,0 +1,26 @@
1
+ import { defineConfig } from '@playwright/test';
2
+
3
+ const BROWSER_TEST_PORT = 43_006;
4
+ const serverCommand = process.env.FLUO_REACT_STARTER_SERVER_COMMAND === 'dev'
5
+ ? <%= devCommandJson %>
6
+ : <%= startCommandJson %>;
7
+
8
+ export default defineConfig({
9
+ fullyParallel: false,
10
+ outputDir: 'node_modules/.cache/playwright-results',
11
+ testDir: './tests',
12
+ timeout: 30_000,
13
+ use: {
14
+ baseURL: `http://127.0.0.1:${BROWSER_TEST_PORT}`,
15
+ channel: 'chrome',
16
+ headless: true,
17
+ },
18
+ webServer: {
19
+ command: serverCommand,
20
+ env: { PORT: String(BROWSER_TEST_PORT) },
21
+ reuseExistingServer: false,
22
+ timeout: 30_000,
23
+ url: `http://127.0.0.1:${BROWSER_TEST_PORT}/assets/entry-client.js`,
24
+ },
25
+ workers: 1,
26
+ });
@@ -0,0 +1,82 @@
1
+ import { createTestApp } from '@fluojs/testing';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createAppModule } from './app';
5
+ import { ReactStarterEntryError, createReactPageRenderer } from './entry-server';
6
+
7
+ const VITE_MANIFEST = {
8
+ 'src/entry-client.tsx': {
9
+ css: ['styles.css'],
10
+ file: 'entry-client.js',
11
+ isEntry: true,
12
+ src: 'src/entry-client.tsx',
13
+ },
14
+ 'src/entry-server.tsx': {
15
+ file: 'entry-server.js',
16
+ isEntry: true,
17
+ src: 'src/entry-server.tsx',
18
+ },
19
+ } as const;
20
+
21
+ function readHtml(body: unknown): string {
22
+ if (body instanceof Uint8Array) {
23
+ return new TextDecoder().decode(body);
24
+ }
25
+ return typeof body === 'string' ? body : JSON.stringify(body);
26
+ }
27
+
28
+ describe('React Vite SSR starter', () => {
29
+ it('streams an HTTP-owned page with manifest-derived hydration assets', async () => {
30
+ const AppModule = createAppModule({
31
+ clientDirectory: new URL('../dist/client/', import.meta.url),
32
+ renderPage: createReactPageRenderer(VITE_MANIFEST),
33
+ });
34
+ const app = await createTestApp({ rootModule: AppModule });
35
+
36
+ try {
37
+ const response = await app.request('GET', '/products/sku-42').query('preview', 'true').send();
38
+ const html = readHtml(response.body);
39
+
40
+ expect(response.status).toBe(200);
41
+ expect(response.headers['Content-Type']).toBe('text/html; charset=utf-8');
42
+ expect(html).toContain('Catalog item sku-42');
43
+ expect(html).toContain('Preview mode');
44
+ expect(html).toContain('src="/assets/entry-client.js"');
45
+ expect(html).toContain('href="/assets/styles.css"');
46
+ expect(html).toContain('href="/products/sku-84?preview=false"');
47
+ } finally {
48
+ await app.close();
49
+ }
50
+ });
51
+
52
+ it('keeps route validation on the server DTO boundary', async () => {
53
+ const AppModule = createAppModule({
54
+ clientDirectory: new URL('../dist/client/', import.meta.url),
55
+ renderPage: createReactPageRenderer(VITE_MANIFEST),
56
+ });
57
+ const app = await createTestApp({ rootModule: AppModule });
58
+
59
+ try {
60
+ const response = await app.request('GET', '/products/x').query('preview', 'maybe').send();
61
+ expect(response.status).toBe(400);
62
+ } finally {
63
+ await app.close();
64
+ }
65
+ });
66
+
67
+ it('identifies the application entry files when the Vite manifest is incompatible', () => {
68
+ try {
69
+ createReactPageRenderer({});
70
+ throw new TypeError('Expected createReactPageRenderer(...) to reject an incompatible manifest.');
71
+ } catch (error) {
72
+ expect(error).toBeInstanceOf(ReactStarterEntryError);
73
+ if (error instanceof ReactStarterEntryError) {
74
+ expect(error.code).toBe('react-starter-entry-incompatible');
75
+ expect(error.message).toContain('src/entry-server.tsx');
76
+ expect(error.message).toContain('src/entry-client.tsx');
77
+ expect(error.message).toContain('vite.client.config.ts');
78
+ expect(error.message).toContain('generated build script');
79
+ }
80
+ }
81
+ });
82
+ });
@@ -0,0 +1,91 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ import { Module } from '@fluojs/core';
4
+ import {
5
+ Controller,
6
+ FromPath,
7
+ FromQuery,
8
+ Get,
9
+ NotFoundException,
10
+ Optional,
11
+ RequestDto,
12
+ type RequestContext,
13
+ } from '@fluojs/http';
14
+ import {
15
+ Path,
16
+ ReactModule,
17
+ Router,
18
+ type ReactPageRenderer,
19
+ } from '@fluojs/react';
20
+ import { IsIn, IsString, MinLength } from '@fluojs/validation';
21
+ import { createElement, type ReactElement } from 'react';
22
+
23
+ import { ProductPage } from './page';
24
+
25
+ const ASSET_FILE_PATTERN = /^[a-zA-Z0-9._-]+\.(?:css|js)$/u;
26
+
27
+ export type ReactViteAppModuleOptions = {
28
+ readonly clientDirectory: URL;
29
+ readonly renderPage: ReactPageRenderer;
30
+ };
31
+
32
+ class ProductPageRequest {
33
+ @MinLength(3)
34
+ @IsString()
35
+ @FromPath('sku')
36
+ sku = '';
37
+
38
+ @IsIn(['true', 'false'])
39
+ @Optional()
40
+ @FromQuery('preview')
41
+ preview?: string;
42
+ }
43
+
44
+ class AssetRequest {
45
+ @FromPath('file')
46
+ file = '';
47
+ }
48
+
49
+ export function createAppModule(options: ReactViteAppModuleOptions) {
50
+ @Router('/products')
51
+ class ProductPageRouter {
52
+ @Path('/:sku')
53
+ @RequestDto(ProductPageRequest)
54
+ show(_input: ProductPageRequest): ReactElement {
55
+ return createElement(ProductPage);
56
+ }
57
+ }
58
+
59
+ @Controller('/assets')
60
+ class ViteAssetController {
61
+ @Get('/:file')
62
+ @RequestDto(AssetRequest)
63
+ async serve(input: AssetRequest, context: RequestContext): Promise<Uint8Array> {
64
+ if (!ASSET_FILE_PATTERN.test(input.file)) {
65
+ throw new NotFoundException('Vite asset not found.');
66
+ }
67
+
68
+ try {
69
+ const body = await readFile(new URL(input.file, options.clientDirectory));
70
+ context.response.setHeader(
71
+ 'Content-Type',
72
+ input.file.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8',
73
+ );
74
+ return body;
75
+ } catch (error) {
76
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
77
+ throw new NotFoundException('Vite asset not found.', { cause: error });
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+ }
83
+
84
+ @Module({
85
+ controllers: [ViteAssetController],
86
+ imports: [ReactModule.forRoot({ controllers: [ProductPageRouter], renderPage: options.renderPage })],
87
+ })
88
+ class AppModule {}
89
+
90
+ return AppModule;
91
+ }
@@ -0,0 +1,29 @@
1
+ import { hydrateRoot } from 'react-dom/client';
2
+
3
+ import { ProductPage } from './page';
4
+ import {
5
+ ReactAppDocument,
6
+ REACT_IDENTIFIER_PREFIX,
7
+ reportReactHydrationMismatch,
8
+ } from './react-app';
9
+ import './styles.css';
10
+
11
+ const stylesheets = [...document.querySelectorAll<HTMLLinkElement>('link[data-vite-style]')]
12
+ .map((link) => link.getAttribute('href'))
13
+ .filter((href): href is string => href !== null);
14
+
15
+ hydrateRoot(
16
+ document,
17
+ <ReactAppDocument
18
+ page={<ProductPage />}
19
+ route={{
20
+ params: { sku: document.documentElement.dataset.routeSku ?? '' },
21
+ url: `${window.location.pathname}${window.location.search}`,
22
+ }}
23
+ stylesheets={stylesheets}
24
+ />,
25
+ {
26
+ identifierPrefix: REACT_IDENTIFIER_PREFIX,
27
+ onRecoverableError: reportReactHydrationMismatch,
28
+ },
29
+ );
@@ -0,0 +1,52 @@
1
+ import {
2
+ createReactServerEntry,
3
+ type ReactPageRenderer,
4
+ } from '@fluojs/react';
5
+ import { createReactViteAssetManifest } from '@fluojs/react/vite';
6
+
7
+ import { ReactAppDocument, REACT_IDENTIFIER_PREFIX } from './react-app';
8
+
9
+ export class ReactStarterEntryError extends Error {
10
+ readonly code = 'react-starter-entry-incompatible' as const;
11
+
12
+ constructor(readonly diagnostics: readonly string[]) {
13
+ super(
14
+ [
15
+ 'The React starter could not match its server and client entries.',
16
+ 'Check src/entry-server.tsx, src/entry-client.tsx, and vite.client.config.ts, then run the generated build script again.',
17
+ ...diagnostics,
18
+ ].join('\n'),
19
+ );
20
+ this.name = 'ReactStarterEntryError';
21
+ }
22
+ }
23
+
24
+ export function createReactPageRenderer(manifest: unknown): ReactPageRenderer {
25
+ const result = createReactViteAssetManifest({
26
+ base: '/assets/',
27
+ entries: {
28
+ client: 'src/entry-client.tsx',
29
+ server: 'src/entry-server.tsx',
30
+ },
31
+ identifierPrefix: REACT_IDENTIFIER_PREFIX,
32
+ manifest,
33
+ });
34
+
35
+ if (!result.ok) {
36
+ throw new ReactStarterEntryError(
37
+ result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`),
38
+ );
39
+ }
40
+
41
+ const assets = result.manifest;
42
+ const renderPage: ReactPageRenderer = (page, context) => createReactServerEntry(
43
+ <ReactAppDocument
44
+ page={page}
45
+ route={{ params: context.request.params, url: context.request.url }}
46
+ stylesheets={assets.css}
47
+ />,
48
+ assets.hydrationOptions,
49
+ );
50
+
51
+ return renderPage;
52
+ }
@@ -0,0 +1,55 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+
8
+ import { ReactStarterManifestError, loadReactViteManifest } from './load-manifest';
9
+
10
+ const temporaryDirectories: string[] = [];
11
+
12
+ afterEach(async () => {
13
+ await Promise.all(
14
+ temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })),
15
+ );
16
+ });
17
+
18
+ describe('React Vite manifest loading', () => {
19
+ it('identifies the build command when the client manifest is missing', async () => {
20
+ const directory = await mkdtemp(join(tmpdir(), 'fluo-react-manifest-missing-'));
21
+ temporaryDirectories.push(directory);
22
+
23
+ try {
24
+ await loadReactViteManifest(pathToFileURL(join(directory, 'manifest.json')));
25
+ throw new TypeError('Expected loadReactViteManifest(...) to reject a missing manifest.');
26
+ } catch (error) {
27
+ expect(error).toBeInstanceOf(ReactStarterManifestError);
28
+ if (error instanceof ReactStarterManifestError) {
29
+ expect(error.code).toBe('react-starter-manifest-missing');
30
+ expect(error.message).toContain('generated build script');
31
+ expect(error.message).toContain('generated start script');
32
+ expect(error.message).toContain('vite.client.config.ts');
33
+ }
34
+ }
35
+ });
36
+
37
+ it('identifies generated build output when the client manifest is malformed', async () => {
38
+ const directory = await mkdtemp(join(tmpdir(), 'fluo-react-manifest-malformed-'));
39
+ temporaryDirectories.push(directory);
40
+ const manifestPath = join(directory, 'manifest.json');
41
+ await writeFile(manifestPath, '{');
42
+
43
+ try {
44
+ await loadReactViteManifest(pathToFileURL(manifestPath));
45
+ throw new TypeError('Expected loadReactViteManifest(...) to reject malformed JSON.');
46
+ } catch (error) {
47
+ expect(error).toBeInstanceOf(ReactStarterManifestError);
48
+ if (error instanceof ReactStarterManifestError) {
49
+ expect(error.code).toBe('react-starter-manifest-malformed');
50
+ expect(error.message).toContain('dist/client/.vite/manifest.json');
51
+ expect(error.message).toContain('generated build script');
52
+ }
53
+ }
54
+ });
55
+ });
@@ -0,0 +1,53 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ export type ReactStarterManifestErrorCode =
4
+ | 'react-starter-manifest-malformed'
5
+ | 'react-starter-manifest-missing'
6
+ | 'react-starter-manifest-read-failed';
7
+
8
+ export class ReactStarterManifestError extends Error {
9
+ constructor(
10
+ readonly code: ReactStarterManifestErrorCode,
11
+ message: string,
12
+ options: ErrorOptions,
13
+ ) {
14
+ super(message, options);
15
+ this.name = 'ReactStarterManifestError';
16
+ }
17
+ }
18
+
19
+ export async function loadReactViteManifest(manifestUrl: URL): Promise<unknown> {
20
+ let source: string;
21
+
22
+ try {
23
+ source = await readFile(manifestUrl, 'utf8');
24
+ } catch (error) {
25
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
26
+ throw new ReactStarterManifestError(
27
+ 'react-starter-manifest-missing',
28
+ 'The Vite client manifest is missing. Run the generated build script before the generated start script; if it remains missing, check build.manifest and build.outDir in vite.client.config.ts.',
29
+ { cause: error },
30
+ );
31
+ }
32
+
33
+ throw new ReactStarterManifestError(
34
+ 'react-starter-manifest-read-failed',
35
+ 'The Vite client manifest could not be read. Check the generated dist/client directory and file permissions, then run the generated build script again.',
36
+ { cause: error },
37
+ );
38
+ }
39
+
40
+ try {
41
+ const manifest: unknown = JSON.parse(source);
42
+ return manifest;
43
+ } catch (error) {
44
+ if (error instanceof SyntaxError) {
45
+ throw new ReactStarterManifestError(
46
+ 'react-starter-manifest-malformed',
47
+ 'The Vite client manifest is not valid JSON. Remove dist, run the generated build script again, and do not edit dist/client/.vite/manifest.json by hand.',
48
+ { cause: error },
49
+ );
50
+ }
51
+ throw error;
52
+ }
53
+ }