@fluojs/cli 1.1.0 → 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.
- package/README.ko.md +183 -22
- package/README.md +186 -22
- package/dist/cli.d.ts +5 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +128 -35
- package/dist/commands/diagnostics.d.ts +35 -0
- package/dist/commands/diagnostics.d.ts.map +1 -1
- package/dist/commands/diagnostics.js +60 -0
- package/dist/commands/generate.d.ts +1 -49
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +1 -214
- package/dist/commands/inspect.d.ts +5 -7
- package/dist/commands/inspect.d.ts.map +1 -1
- package/dist/commands/inspect.js +85 -65
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +27 -15
- package/dist/commands/migration-transform-tokens.d.ts +15 -0
- package/dist/commands/migration-transform-tokens.d.ts.map +1 -0
- package/dist/commands/migration-transform-tokens.js +41 -0
- package/dist/commands/new.d.ts +0 -6
- package/dist/commands/new.d.ts.map +1 -1
- package/dist/commands/new.js +42 -96
- package/dist/commands/output-path-safety.d.ts +14 -0
- package/dist/commands/output-path-safety.d.ts.map +1 -0
- package/dist/commands/output-path-safety.js +34 -0
- package/dist/commands/scripts.d.ts +1 -1
- package/dist/commands/scripts.d.ts.map +1 -1
- package/dist/commands/scripts.js +13 -7
- package/dist/commands/typegen-artifact.d.ts +54 -0
- package/dist/commands/typegen-artifact.d.ts.map +1 -0
- package/dist/commands/typegen-artifact.js +117 -0
- package/dist/commands/typegen-generation-child.d.ts +2 -0
- package/dist/commands/typegen-generation-child.d.ts.map +1 -0
- package/dist/commands/typegen-generation-child.js +59 -0
- package/dist/commands/typegen-generation-process.d.ts +46 -0
- package/dist/commands/typegen-generation-process.d.ts.map +1 -0
- package/dist/commands/typegen-generation-process.js +131 -0
- package/dist/commands/typegen-generation-protocol.d.ts +16 -0
- package/dist/commands/typegen-generation-protocol.d.ts.map +1 -0
- package/dist/commands/typegen-generation-protocol.js +35 -0
- package/dist/commands/typegen-isolated-source.d.ts +10 -0
- package/dist/commands/typegen-isolated-source.d.ts.map +1 -0
- package/dist/commands/typegen-isolated-source.js +75 -0
- package/dist/commands/typegen-options.d.ts +20 -0
- package/dist/commands/typegen-options.d.ts.map +1 -0
- package/dist/commands/typegen-options.js +71 -0
- package/dist/commands/typegen-source.d.ts +59 -0
- package/dist/commands/typegen-source.d.ts.map +1 -0
- package/dist/commands/typegen-source.js +183 -0
- package/dist/commands/typegen-watch.d.ts +46 -0
- package/dist/commands/typegen-watch.d.ts.map +1 -0
- package/dist/commands/typegen-watch.js +212 -0
- package/dist/commands/typegen.d.ts +29 -0
- package/dist/commands/typegen.d.ts.map +1 -0
- package/dist/commands/typegen.js +119 -0
- package/dist/dev-runner/node-restart-runner.d.ts +6 -0
- package/dist/dev-runner/node-restart-runner.d.ts.map +1 -1
- package/dist/dev-runner/node-restart-runner.js +64 -13
- package/dist/fixtures/inspect-react-app.module.d.ts +4 -0
- package/dist/fixtures/inspect-react-app.module.d.ts.map +1 -0
- package/dist/fixtures/inspect-react-app.module.js +32 -0
- package/dist/fixtures/typegen-react-app.module.d.ts +4 -0
- package/dist/fixtures/typegen-react-app.module.d.ts.map +1 -0
- package/dist/fixtures/typegen-react-app.module.js +33 -0
- package/dist/generate-command.d.ts +50 -0
- package/dist/generate-command.d.ts.map +1 -0
- package/dist/generate-command.js +214 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/new/install.js +1 -1
- package/dist/new/package-spec-resolver.d.ts.map +1 -1
- package/dist/new/package-spec-resolver.js +50 -59
- package/dist/new/prompt.d.ts +1 -0
- package/dist/new/prompt.d.ts.map +1 -1
- package/dist/new/prompt.js +40 -12
- package/dist/new/published-internal-dependencies.d.ts +20 -0
- package/dist/new/published-internal-dependencies.d.ts.map +1 -0
- package/dist/new/published-internal-dependencies.js +20 -0
- package/dist/new/react-vite-ssr-scaffold.d.ts +19 -0
- package/dist/new/react-vite-ssr-scaffold.d.ts.map +1 -0
- package/dist/new/react-vite-ssr-scaffold.js +108 -0
- package/dist/new/resolver.d.ts +2 -2
- package/dist/new/resolver.d.ts.map +1 -1
- package/dist/new/resolver.js +5 -1
- package/dist/new/scaffold.d.ts.map +1 -1
- package/dist/new/scaffold.js +177 -54
- package/dist/new/starter-profiles.d.ts +8 -4
- package/dist/new/starter-profiles.d.ts.map +1 -1
- package/dist/new/starter-profiles.js +52 -18
- package/dist/new/templates/react-vite-ssr/README.md.ejs +74 -0
- package/dist/new/templates/react-vite-ssr/playwright.config.ts.ejs +26 -0
- package/dist/new/templates/react-vite-ssr/src/app.test.ts.ejs +82 -0
- package/dist/new/templates/react-vite-ssr/src/app.ts.ejs +91 -0
- package/dist/new/templates/react-vite-ssr/src/entry-client.tsx.ejs +29 -0
- package/dist/new/templates/react-vite-ssr/src/entry-server.tsx.ejs +52 -0
- package/dist/new/templates/react-vite-ssr/src/load-manifest.test.ts.ejs +55 -0
- package/dist/new/templates/react-vite-ssr/src/load-manifest.ts.ejs +53 -0
- package/dist/new/templates/react-vite-ssr/src/main.ts.ejs +18 -0
- package/dist/new/templates/react-vite-ssr/src/page.tsx.ejs +43 -0
- package/dist/new/templates/react-vite-ssr/src/react-app.test.tsx.ejs +67 -0
- package/dist/new/templates/react-vite-ssr/src/react-app.tsx.ejs +46 -0
- package/dist/new/templates/react-vite-ssr/src/styles.css.ejs +25 -0
- package/dist/new/templates/react-vite-ssr/src/styles.d.ts.ejs +1 -0
- package/dist/new/templates/react-vite-ssr/tests/production-hydration.spec.ts.ejs +54 -0
- package/dist/new/templates/react-vite-ssr/tsconfig.json.ejs +16 -0
- package/dist/new/templates/react-vite-ssr/vite.client.config.ts.ejs +24 -0
- package/dist/new/templates/react-vite-ssr/vite.server.config.ts.ejs +17 -0
- package/dist/new/templates/react-vite-ssr/vitest.config.ts.ejs +10 -0
- package/dist/new/types.d.ts +4 -2
- package/dist/new/types.d.ts.map +1 -1
- package/dist/public-generate.d.ts +2 -0
- package/dist/public-generate.d.ts.map +1 -0
- package/dist/public-generate.js +1 -0
- package/dist/public-inspect.d.ts +13 -0
- package/dist/public-inspect.d.ts.map +1 -0
- package/dist/public-inspect.js +16 -0
- package/dist/public-new.d.ts +13 -0
- package/dist/public-new.d.ts.map +1 -0
- package/dist/public-new.js +16 -0
- package/dist/public-typegen.d.ts +14 -0
- package/dist/public-typegen.d.ts.map +1 -0
- package/dist/public-typegen.js +17 -0
- package/dist/run-cli.d.ts +2 -1
- package/dist/run-cli.d.ts.map +1 -1
- package/dist/studio/sidecar.d.ts.map +1 -1
- package/dist/studio/sidecar.js +106 -14
- package/dist/transforms/nestjs-migrate.d.ts +4 -1
- package/dist/transforms/nestjs-migrate.d.ts.map +1 -1
- package/dist/transforms/nestjs-migrate.js +625 -65
- package/dist/typegen-contract.d.ts +10 -0
- package/dist/typegen-contract.d.ts.map +1 -0
- package/dist/typegen-contract.js +9 -0
- package/dist/usage.d.ts +19 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +178 -0
- package/package.json +19 -10
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createFastifyAdapter } from '@fluojs/platform-fastify';
|
|
2
|
+
import { FluoFactory } from '@fluojs/runtime';
|
|
3
|
+
|
|
4
|
+
import { createAppModule } from './app';
|
|
5
|
+
import { createReactPageRenderer } from './entry-server';
|
|
6
|
+
import { loadReactViteManifest } from './load-manifest';
|
|
7
|
+
|
|
8
|
+
const manifest = await loadReactViteManifest(new URL('../client/.vite/manifest.json', import.meta.url));
|
|
9
|
+
const AppModule = createAppModule({
|
|
10
|
+
clientDirectory: new URL('../client/', import.meta.url),
|
|
11
|
+
renderPage: createReactPageRenderer(manifest),
|
|
12
|
+
});
|
|
13
|
+
const port = Number(process.env.PORT ?? '3000');
|
|
14
|
+
const app = await FluoFactory.create(AppModule, {
|
|
15
|
+
adapter: createFastifyAdapter({ host: '127.0.0.1', port }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await app.listen();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Link,
|
|
3
|
+
useParams,
|
|
4
|
+
useRouter,
|
|
5
|
+
useSearchParams,
|
|
6
|
+
} from '@fluojs/react/client';
|
|
7
|
+
import { type ReactElement, useId, useState } from 'react';
|
|
8
|
+
|
|
9
|
+
export function HydratedCounter(): ReactElement {
|
|
10
|
+
const [count, setCount] = useState(0);
|
|
11
|
+
return <button onClick={() => setCount((value) => value + 1)} type='button'>Count: {count}</button>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ProductNavigation(): ReactElement {
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<nav aria-label='Product navigation'>
|
|
19
|
+
<Link href='/products/sku-84?preview=false'>Open sku-84</Link>
|
|
20
|
+
<button onClick={() => router.push('/products/sku-126?preview=true')} type='button'>
|
|
21
|
+
Push sku-126
|
|
22
|
+
</button>
|
|
23
|
+
</nav>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ProductPage(): ReactElement {
|
|
28
|
+
const identifier = useId();
|
|
29
|
+
const params = useParams();
|
|
30
|
+
const searchParams = useSearchParams();
|
|
31
|
+
const sku = params['sku'] ?? 'unknown';
|
|
32
|
+
const preview = searchParams.get('preview') === 'true';
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<main>
|
|
36
|
+
<h1>{`Catalog item ${sku}`}</h1>
|
|
37
|
+
<p>{preview ? 'Preview mode' : 'Published mode'}</p>
|
|
38
|
+
<p data-react-identifier id={identifier}>Shared hydration identifier</p>
|
|
39
|
+
<HydratedCounter />
|
|
40
|
+
<ProductNavigation />
|
|
41
|
+
</main>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { ReactClientRouterProvider, createReactRouteSnapshot } from '@fluojs/react/client';
|
|
4
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
5
|
+
import { renderToString } from 'react-dom/server';
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { HydratedCounter, ProductPage } from './page';
|
|
9
|
+
import { ReactAppDocument, reportReactHydrationMismatch } from './react-app';
|
|
10
|
+
|
|
11
|
+
describe('React Vite SSR app composition', () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.replaceChildren();
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders the HTTP-owned route snapshot through the shared document', () => {
|
|
18
|
+
const html = renderToString(
|
|
19
|
+
<ReactAppDocument
|
|
20
|
+
page={<ProductPage />}
|
|
21
|
+
route={{ params: { sku: 'sku-42' }, url: '/products/sku-42?preview=true' }}
|
|
22
|
+
stylesheets={['/assets/styles.css']}
|
|
23
|
+
/>,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(html).toContain('Catalog item sku-42');
|
|
27
|
+
expect(html).toContain('Preview mode');
|
|
28
|
+
expect(html).toContain('href="/assets/styles.css"');
|
|
29
|
+
expect(html).toContain('data-route-sku="sku-42"');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reports actionable application files when hydration mismatches', () => {
|
|
33
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
34
|
+
const mismatch = new Error('Hydration mismatch');
|
|
35
|
+
|
|
36
|
+
reportReactHydrationMismatch(mismatch);
|
|
37
|
+
|
|
38
|
+
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('src/entry-client.tsx'), mismatch);
|
|
39
|
+
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('src/entry-server.tsx'), mismatch);
|
|
40
|
+
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('src/react-app.tsx'), mismatch);
|
|
41
|
+
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('vite.client.config.ts'), mismatch);
|
|
42
|
+
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('generated build script'), mismatch);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('attaches interaction without hydration warnings', async () => {
|
|
46
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
47
|
+
const container = document.createElement('div');
|
|
48
|
+
const initialSnapshot = createReactRouteSnapshot({ url: '/products/sku-42' });
|
|
49
|
+
const counter = (
|
|
50
|
+
<ReactClientRouterProvider initialSnapshot={initialSnapshot}>
|
|
51
|
+
<HydratedCounter />
|
|
52
|
+
</ReactClientRouterProvider>
|
|
53
|
+
);
|
|
54
|
+
container.innerHTML = renderToString(counter);
|
|
55
|
+
document.body.append(container);
|
|
56
|
+
|
|
57
|
+
const root = hydrateRoot(container, counter);
|
|
58
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
59
|
+
const button = document.querySelector('button');
|
|
60
|
+
button?.click();
|
|
61
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
62
|
+
|
|
63
|
+
expect(button?.textContent).toBe('Count: 1');
|
|
64
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
65
|
+
root.unmount();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ReactClientRouterProvider,
|
|
3
|
+
createReactRouteSnapshot,
|
|
4
|
+
type ReactRouteSnapshotInput,
|
|
5
|
+
} from '@fluojs/react/client';
|
|
6
|
+
import type { ReactElement } from 'react';
|
|
7
|
+
|
|
8
|
+
export const REACT_IDENTIFIER_PREFIX = 'fluo-react-vite-' as const;
|
|
9
|
+
|
|
10
|
+
export type ReactAppDocumentProps = {
|
|
11
|
+
readonly page: ReactElement;
|
|
12
|
+
readonly route: ReactRouteSnapshotInput;
|
|
13
|
+
readonly stylesheets: readonly string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function reportReactHydrationMismatch(error: unknown): void {
|
|
17
|
+
console.error(
|
|
18
|
+
'React hydration did not match. Compare src/entry-client.tsx, src/entry-server.tsx, and src/react-app.tsx; check vite.client.config.ts, then run the generated build script again.',
|
|
19
|
+
error,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ReactAppDocument({
|
|
24
|
+
page,
|
|
25
|
+
route,
|
|
26
|
+
stylesheets,
|
|
27
|
+
}: ReactAppDocumentProps): ReactElement {
|
|
28
|
+
const initialSnapshot = createReactRouteSnapshot(route);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<ReactClientRouterProvider initialSnapshot={initialSnapshot}>
|
|
32
|
+
<html data-route-sku={initialSnapshot.params['sku'] ?? ''} lang='en'>
|
|
33
|
+
<head>
|
|
34
|
+
<meta charSet='utf-8' />
|
|
35
|
+
<meta content='width=device-width, initial-scale=1' name='viewport' />
|
|
36
|
+
<link href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>' rel='icon' />
|
|
37
|
+
<title>Fluo React starter</title>
|
|
38
|
+
{stylesheets.map((href) => (
|
|
39
|
+
<link data-vite-style href={href} key={href} rel='stylesheet' />
|
|
40
|
+
))}
|
|
41
|
+
</head>
|
|
42
|
+
<body>{page}</body>
|
|
43
|
+
</html>
|
|
44
|
+
</ReactClientRouterProvider>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color: #172033;
|
|
3
|
+
font-family: system-ui, sans-serif;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
body {
|
|
7
|
+
margin: 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
main {
|
|
11
|
+
margin: 0 auto;
|
|
12
|
+
max-width: 48rem;
|
|
13
|
+
padding: 4rem 1.5rem;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
nav {
|
|
17
|
+
display: grid;
|
|
18
|
+
gap: 0.75rem;
|
|
19
|
+
margin-top: 2rem;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
button,
|
|
23
|
+
a {
|
|
24
|
+
font: inherit;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module '*.css';
|