@nestjs-ssr/react 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -73
- package/dist/templates/entry-client.tsx +29 -8
- package/package.json +1 -1
- package/src/templates/entry-client.tsx +29 -8
package/README.md
CHANGED
|
@@ -12,23 +12,29 @@ If you value [Uncle Bob's Clean Architecture](https://blog.cleancoder.com/uncle-
|
|
|
12
12
|
|
|
13
13
|
**Clear Separation of Concerns:**
|
|
14
14
|
```typescript
|
|
15
|
+
// View component with typed props (Presentation Layer)
|
|
16
|
+
export interface UserProfileProps {
|
|
17
|
+
user: User;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function UserProfile(props: PageProps<UserProfileProps>) {
|
|
21
|
+
return <div>{props.user.name}</div>; // Pure presentation
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
// Server logic stays in controllers (Application Layer)
|
|
25
|
+
import UserProfile from './views/user-profile';
|
|
26
|
+
|
|
16
27
|
@Controller()
|
|
17
28
|
export class UserController {
|
|
18
29
|
constructor(private userService: UserService) {} // Proper DI
|
|
19
30
|
|
|
20
31
|
@Get('/users/:id')
|
|
21
|
-
@Render(
|
|
32
|
+
@Render(UserProfile) // Type-safe! Cmd+Click to navigate
|
|
22
33
|
async getUserProfile(@Param('id') id: string) {
|
|
23
34
|
const user = await this.userService.findById(id); // Business logic
|
|
24
|
-
return { user }; //
|
|
35
|
+
return { user }; // ✅ TypeScript validates this matches UserProfileProps
|
|
25
36
|
}
|
|
26
37
|
}
|
|
27
|
-
|
|
28
|
-
// View logic stays in React components (Presentation Layer)
|
|
29
|
-
export default function UserProfile({ data }: PageProps<{ user: User }>) {
|
|
30
|
-
return <div>{data.user.name}</div>; // Pure presentation
|
|
31
|
-
}
|
|
32
38
|
```
|
|
33
39
|
|
|
34
40
|
**No Server/Client Confusion:**
|
|
@@ -73,24 +79,30 @@ export default function Page() {
|
|
|
73
79
|
|
|
74
80
|
**NestJS SSR maintains boundaries:**
|
|
75
81
|
```tsx
|
|
82
|
+
// ✅ View: Client-only, pure presentation
|
|
83
|
+
export interface ProductsProps {
|
|
84
|
+
products: Product[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default function Products(props: PageProps<ProductsProps>) {
|
|
88
|
+
const [selected, setSelected] = useState(null);
|
|
89
|
+
return <ProductList products={props.products} onSelect={setSelected} />;
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
// ✅ Controller: Server-only, testable, uses DI
|
|
93
|
+
import Products from './views/products';
|
|
94
|
+
|
|
77
95
|
@Controller()
|
|
78
96
|
export class ProductController {
|
|
79
97
|
constructor(private productService: ProductService) {}
|
|
80
98
|
|
|
81
99
|
@Get('/products')
|
|
82
100
|
@UseGuards(AuthGuard) // Proper middleware
|
|
83
|
-
@Render(
|
|
101
|
+
@Render(Products) // Type-safe component reference
|
|
84
102
|
async list() {
|
|
85
103
|
return { products: await this.productService.findAll() };
|
|
86
104
|
}
|
|
87
105
|
}
|
|
88
|
-
|
|
89
|
-
// ✅ View: Client-only, pure presentation
|
|
90
|
-
export default function Products({ data }: PageProps) {
|
|
91
|
-
const [selected, setSelected] = useState(null);
|
|
92
|
-
return <ProductList products={data.products} onSelect={setSelected} />;
|
|
93
|
-
}
|
|
94
106
|
```
|
|
95
107
|
|
|
96
108
|
### Performance as a Bonus
|
|
@@ -105,6 +117,8 @@ Following clean architecture doesn't mean sacrificing performance. In our benchm
|
|
|
105
117
|
|
|
106
118
|
## Features
|
|
107
119
|
|
|
120
|
+
✅ **Type-Safe Props** - Automatic validation of controller return types against component props
|
|
121
|
+
✅ **IDE Navigation** - Cmd+Click on components to jump to view files
|
|
108
122
|
✅ **Architectural Integrity** - Respects SOLID and Clean Architecture principles
|
|
109
123
|
✅ **Dependency Injection** - Full NestJS DI throughout your application
|
|
110
124
|
✅ **Clear Boundaries** - Server code is server, client code is client
|
|
@@ -129,10 +143,15 @@ npm install @nestjs-ssr/react react react-dom vite
|
|
|
129
143
|
// vite.config.ts
|
|
130
144
|
import { defineConfig } from 'vite';
|
|
131
145
|
import react from '@vitejs/plugin-react';
|
|
132
|
-
import {
|
|
146
|
+
import { resolve } from 'path';
|
|
133
147
|
|
|
134
148
|
export default defineConfig({
|
|
135
|
-
plugins: [react()
|
|
149
|
+
plugins: [react()],
|
|
150
|
+
resolve: {
|
|
151
|
+
alias: {
|
|
152
|
+
'@': resolve(__dirname, 'src'),
|
|
153
|
+
},
|
|
154
|
+
},
|
|
136
155
|
});
|
|
137
156
|
```
|
|
138
157
|
|
|
@@ -151,18 +170,18 @@ import { RenderModule } from '@nestjs-ssr/react';
|
|
|
151
170
|
export class AppModule {}
|
|
152
171
|
```
|
|
153
172
|
|
|
154
|
-
### 3. Create a View
|
|
173
|
+
### 3. Create a View Component
|
|
155
174
|
|
|
156
175
|
```typescript
|
|
157
176
|
// src/views/home.tsx
|
|
158
177
|
import type { PageProps } from '@nestjs-ssr/react';
|
|
159
178
|
|
|
160
|
-
interface
|
|
179
|
+
export interface HomeProps {
|
|
161
180
|
message: string;
|
|
162
181
|
}
|
|
163
182
|
|
|
164
|
-
export default function Home(
|
|
165
|
-
return <h1>{
|
|
183
|
+
export default function Home(props: PageProps<HomeProps>) {
|
|
184
|
+
return <h1>{props.message}</h1>;
|
|
166
185
|
}
|
|
167
186
|
```
|
|
168
187
|
|
|
@@ -172,61 +191,21 @@ export default function Home({ data }: PageProps<HomeData>) {
|
|
|
172
191
|
// app.controller.ts
|
|
173
192
|
import { Controller, Get } from '@nestjs/common';
|
|
174
193
|
import { Render } from '@nestjs-ssr/react';
|
|
194
|
+
import Home from './views/home';
|
|
175
195
|
|
|
176
196
|
@Controller()
|
|
177
197
|
export class AppController {
|
|
178
198
|
@Get()
|
|
179
|
-
@Render(
|
|
199
|
+
@Render(Home) // Type-safe! Cmd+Click to navigate to view
|
|
180
200
|
getHome() {
|
|
181
201
|
return { message: 'Hello from NestJS SSR!' };
|
|
182
202
|
}
|
|
183
203
|
}
|
|
184
204
|
```
|
|
185
205
|
|
|
186
|
-
|
|
206
|
+
That's it! No manual view registry or entry files needed - everything is handled automatically.
|
|
187
207
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
**entry-client.tsx**:
|
|
191
|
-
```typescript
|
|
192
|
-
import { StrictMode } from 'react';
|
|
193
|
-
import { hydrateRoot } from 'react-dom/client';
|
|
194
|
-
import { viewRegistry } from './view-registry.generated';
|
|
195
|
-
|
|
196
|
-
const viewPath = window.__COMPONENT_PATH__;
|
|
197
|
-
const initialProps = window.__INITIAL_STATE__ || {};
|
|
198
|
-
const renderContext = window.__CONTEXT__ || {};
|
|
199
|
-
|
|
200
|
-
const ViewComponent = viewRegistry[viewPath];
|
|
201
|
-
|
|
202
|
-
if (!ViewComponent) {
|
|
203
|
-
throw new Error(`View "${viewPath}" not found in registry`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
hydrateRoot(
|
|
207
|
-
document.getElementById('root')!,
|
|
208
|
-
<StrictMode>
|
|
209
|
-
<ViewComponent data={initialProps} context={renderContext} />
|
|
210
|
-
</StrictMode>
|
|
211
|
-
);
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
**entry-server.tsx**:
|
|
215
|
-
```typescript
|
|
216
|
-
import { viewRegistry } from './view-registry.generated';
|
|
217
|
-
|
|
218
|
-
export function render(viewPath: string, props: any, context: any) {
|
|
219
|
-
const ViewComponent = viewRegistry[viewPath];
|
|
220
|
-
|
|
221
|
-
if (!ViewComponent) {
|
|
222
|
-
throw new Error(`View "${viewPath}" not found in registry`);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return <ViewComponent data={props} context={context} />;
|
|
226
|
-
}
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
### 6. Run
|
|
208
|
+
### 5. Run
|
|
230
209
|
|
|
231
210
|
```bash
|
|
232
211
|
npm run dev
|
|
@@ -238,38 +217,46 @@ Visit [http://localhost:3000](http://localhost:3000) 🎉
|
|
|
238
217
|
|
|
239
218
|
### The `@Render` Decorator
|
|
240
219
|
|
|
241
|
-
The decorator
|
|
220
|
+
The decorator takes a React component and automatically validates that your controller returns the correct props:
|
|
242
221
|
|
|
243
222
|
```typescript
|
|
223
|
+
import UserProfile from './views/user-profile';
|
|
224
|
+
|
|
244
225
|
@Get('/users/:id')
|
|
245
|
-
@Render(
|
|
226
|
+
@Render(UserProfile) // Type-safe! Cmd+Click to navigate
|
|
246
227
|
async getUser(@Param('id') id: string) {
|
|
247
228
|
const user = await this.userService.findOne(id);
|
|
248
|
-
return { user }; //
|
|
229
|
+
return { user }; // ✅ TypeScript validates this matches component props
|
|
249
230
|
}
|
|
250
231
|
```
|
|
251
232
|
|
|
252
233
|
### Type-Safe Props
|
|
253
234
|
|
|
254
|
-
Components receive props with full TypeScript support:
|
|
235
|
+
Components receive props with full TypeScript support and validation:
|
|
255
236
|
|
|
256
237
|
```typescript
|
|
257
|
-
import type { PageProps
|
|
238
|
+
import type { PageProps } from '@nestjs-ssr/react';
|
|
258
239
|
|
|
259
|
-
interface
|
|
240
|
+
export interface UserProfileProps {
|
|
260
241
|
user: User;
|
|
261
242
|
}
|
|
262
243
|
|
|
263
|
-
export default function UserProfile(
|
|
244
|
+
export default function UserProfile(props: PageProps<UserProfileProps>) {
|
|
264
245
|
return (
|
|
265
246
|
<div>
|
|
266
|
-
<h1>{
|
|
267
|
-
<p>Requested from: {context.path}</p>
|
|
247
|
+
<h1>{props.user.name}</h1>
|
|
248
|
+
<p>Requested from: {props.context.path}</p>
|
|
268
249
|
</div>
|
|
269
250
|
);
|
|
270
251
|
}
|
|
271
252
|
```
|
|
272
253
|
|
|
254
|
+
**Benefits:**
|
|
255
|
+
- ✅ Build-time validation - wrong props = compilation error
|
|
256
|
+
- ✅ Cmd+Click navigation from controller to view file
|
|
257
|
+
- ✅ No manual type annotations needed
|
|
258
|
+
- ✅ Refactoring-friendly - rename props with confidence
|
|
259
|
+
|
|
273
260
|
### Request Context
|
|
274
261
|
|
|
275
262
|
Every component receives the request context:
|
|
@@ -9,19 +9,40 @@ const renderContext = window.__CONTEXT__ || {};
|
|
|
9
9
|
// @ts-ignore - Vite-specific API
|
|
10
10
|
const modules: Record<string, { default: React.ComponentType<any> }> = import.meta.glob('@/views/**/*.tsx', { eager: true });
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
for (const module of Object.values(modules)) {
|
|
12
|
+
// Build a map of components with their metadata
|
|
13
|
+
const componentMap = Object.entries(modules).map(([path, module]) => {
|
|
15
14
|
const component = module.default;
|
|
16
15
|
const name = component.displayName || component.name;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
16
|
+
const filename = path.split('/').pop()?.replace('.tsx', '');
|
|
17
|
+
const normalizedFilename = filename ? filename.charAt(0).toUpperCase() + filename.slice(1) : undefined;
|
|
18
|
+
|
|
19
|
+
return { path, component, name, filename, normalizedFilename };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Find the component by matching in this order:
|
|
23
|
+
// 1. Exact match by displayName or function name
|
|
24
|
+
// 2. Match by normalized filename (e.g., "home.tsx" -> "Home")
|
|
25
|
+
// 3. If only one component exists, use it (regardless of name)
|
|
26
|
+
let ViewComponent: React.ComponentType<any> | undefined;
|
|
27
|
+
|
|
28
|
+
// Try exact name match first
|
|
29
|
+
ViewComponent = componentMap.find(
|
|
30
|
+
(c) => c.name === componentName || c.normalizedFilename === componentName || c.filename === componentName.toLowerCase()
|
|
31
|
+
)?.component;
|
|
32
|
+
|
|
33
|
+
// If no match found and component name looks like a generic/minified name (default, default_1, etc.)
|
|
34
|
+
// and there's only one component, use it
|
|
35
|
+
if (!ViewComponent && /^default(_\d+)?$/.test(componentName) && componentMap.length === 1) {
|
|
36
|
+
ViewComponent = componentMap[0].component;
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
if (!ViewComponent) {
|
|
24
|
-
|
|
40
|
+
const availableComponents = Object.entries(modules).map(([path, m]) => {
|
|
41
|
+
const filename = path.split('/').pop()?.replace('.tsx', '');
|
|
42
|
+
const name = m.default.displayName || m.default.name;
|
|
43
|
+
return `${filename} (${name})`;
|
|
44
|
+
}).join(', ');
|
|
45
|
+
throw new Error(`Component "${componentName}" not found in views directory. Available: ${availableComponents}`);
|
|
25
46
|
}
|
|
26
47
|
|
|
27
48
|
hydrateRoot(
|
package/package.json
CHANGED
|
@@ -9,19 +9,40 @@ const renderContext = window.__CONTEXT__ || {};
|
|
|
9
9
|
// @ts-ignore - Vite-specific API
|
|
10
10
|
const modules: Record<string, { default: React.ComponentType<any> }> = import.meta.glob('@/views/**/*.tsx', { eager: true });
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
for (const module of Object.values(modules)) {
|
|
12
|
+
// Build a map of components with their metadata
|
|
13
|
+
const componentMap = Object.entries(modules).map(([path, module]) => {
|
|
15
14
|
const component = module.default;
|
|
16
15
|
const name = component.displayName || component.name;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
16
|
+
const filename = path.split('/').pop()?.replace('.tsx', '');
|
|
17
|
+
const normalizedFilename = filename ? filename.charAt(0).toUpperCase() + filename.slice(1) : undefined;
|
|
18
|
+
|
|
19
|
+
return { path, component, name, filename, normalizedFilename };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Find the component by matching in this order:
|
|
23
|
+
// 1. Exact match by displayName or function name
|
|
24
|
+
// 2. Match by normalized filename (e.g., "home.tsx" -> "Home")
|
|
25
|
+
// 3. If only one component exists, use it (regardless of name)
|
|
26
|
+
let ViewComponent: React.ComponentType<any> | undefined;
|
|
27
|
+
|
|
28
|
+
// Try exact name match first
|
|
29
|
+
ViewComponent = componentMap.find(
|
|
30
|
+
(c) => c.name === componentName || c.normalizedFilename === componentName || c.filename === componentName.toLowerCase()
|
|
31
|
+
)?.component;
|
|
32
|
+
|
|
33
|
+
// If no match found and component name looks like a generic/minified name (default, default_1, etc.)
|
|
34
|
+
// and there's only one component, use it
|
|
35
|
+
if (!ViewComponent && /^default(_\d+)?$/.test(componentName) && componentMap.length === 1) {
|
|
36
|
+
ViewComponent = componentMap[0].component;
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
if (!ViewComponent) {
|
|
24
|
-
|
|
40
|
+
const availableComponents = Object.entries(modules).map(([path, m]) => {
|
|
41
|
+
const filename = path.split('/').pop()?.replace('.tsx', '');
|
|
42
|
+
const name = m.default.displayName || m.default.name;
|
|
43
|
+
return `${filename} (${name})`;
|
|
44
|
+
}).join(', ');
|
|
45
|
+
throw new Error(`Component "${componentName}" not found in views directory. Available: ${availableComponents}`);
|
|
25
46
|
}
|
|
26
47
|
|
|
27
48
|
hydrateRoot(
|