@nestjs-ssr/react 0.2.0 → 0.2.2
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 +1 -1
- package/dist/cli/init.js +339 -91
- package/dist/cli/init.mjs +341 -93
- package/dist/client.d.mts +319 -0
- package/dist/client.d.ts +319 -0
- package/dist/client.js +207 -0
- package/dist/client.mjs +200 -0
- package/dist/{index-C5Knql-9.d.mts → index-CaGD266H.d.ts} +2 -104
- package/dist/{index-C5Knql-9.d.ts → index-Dq1yt0sX.d.mts} +2 -104
- package/dist/index.d.mts +9 -321
- package/dist/index.d.ts +9 -321
- package/dist/index.js +144 -91
- package/dist/index.mjs +115 -63
- package/dist/render/index.d.mts +2 -1
- package/dist/render/index.d.ts +2 -1
- package/dist/render/index.js +134 -81
- package/dist/render/index.mjs +115 -63
- package/dist/render-response.interface-Dc-Kwb09.d.mts +104 -0
- package/dist/render-response.interface-Dc-Kwb09.d.ts +104 -0
- package/dist/templates/entry-client.tsx +6 -3
- package/dist/templates/entry-server.tsx +33 -2
- package/dist/templates/index.html +15 -12
- package/package.json +13 -1
- package/src/templates/entry-client.tsx +6 -3
- package/src/templates/entry-server.tsx +33 -2
- package/src/templates/index.html +15 -12
package/dist/render/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { join, relative } from 'path';
|
|
|
5
5
|
import { uneval } from 'devalue';
|
|
6
6
|
import escapeHtml from 'escape-html';
|
|
7
7
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
8
|
-
import { createElement } from 'react';
|
|
8
|
+
import React, { createElement } from 'react';
|
|
9
9
|
import { switchMap } from 'rxjs/operators';
|
|
10
10
|
|
|
11
11
|
var __defProp = Object.defineProperty;
|
|
@@ -185,8 +185,6 @@ window.__LAYOUTS__ = ${uneval(layoutMetadata)};
|
|
|
185
185
|
TemplateParserService = _ts_decorate([
|
|
186
186
|
Injectable()
|
|
187
187
|
], TemplateParserService);
|
|
188
|
-
|
|
189
|
-
// src/render/error-pages/error-page-development.tsx
|
|
190
188
|
function ErrorPageDevelopment({ error, viewPath, phase }) {
|
|
191
189
|
const stackLines = error.stack ? error.stack.split("\n").slice(1) : [];
|
|
192
190
|
return /* @__PURE__ */ React.createElement("html", {
|
|
@@ -260,8 +258,6 @@ function ErrorPageDevelopment({ error, viewPath, phase }) {
|
|
|
260
258
|
}, /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", null, "View Path:"), " ", viewPath), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", null, "Error Phase:"), " ", phase === "shell" ? "Shell (before streaming started)" : "Streaming (during content delivery)"), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", null, "Environment:"), " Development")))));
|
|
261
259
|
}
|
|
262
260
|
__name(ErrorPageDevelopment, "ErrorPageDevelopment");
|
|
263
|
-
|
|
264
|
-
// src/render/error-pages/error-page-production.tsx
|
|
265
261
|
function ErrorPageProduction() {
|
|
266
262
|
return /* @__PURE__ */ React.createElement("html", {
|
|
267
263
|
lang: "en"
|
|
@@ -671,60 +667,79 @@ var RenderService = class _RenderService {
|
|
|
671
667
|
async renderToStream(viewComponent, data = {}, res, head) {
|
|
672
668
|
const startTime = Date.now();
|
|
673
669
|
let shellReadyTime = 0;
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
670
|
+
return new Promise((resolve, reject) => {
|
|
671
|
+
const executeStream = /* @__PURE__ */ __name(async () => {
|
|
672
|
+
let template = this.template;
|
|
673
|
+
if (this.vite) {
|
|
674
|
+
template = await this.vite.transformIndexHtml("/", template);
|
|
675
|
+
}
|
|
676
|
+
const templateParts = this.templateParser.parseTemplate(template);
|
|
677
|
+
let renderModule;
|
|
678
|
+
if (this.vite) {
|
|
679
|
+
renderModule = await this.vite.ssrLoadModule(this.entryServerPath);
|
|
680
|
+
} else {
|
|
681
|
+
if (this.serverManifest) {
|
|
682
|
+
const manifestEntry = Object.entries(this.serverManifest).find(([key, value]) => value.isEntry && key.includes("entry-server"));
|
|
683
|
+
if (manifestEntry) {
|
|
684
|
+
const [, entry] = manifestEntry;
|
|
685
|
+
const serverPath = join(process.cwd(), "dist/server", entry.file);
|
|
686
|
+
renderModule = await import(serverPath);
|
|
687
|
+
} else {
|
|
688
|
+
throw new Error("Server bundle not found in manifest. Run `pnpm build:server` to generate the server bundle.");
|
|
689
|
+
}
|
|
690
690
|
} else {
|
|
691
691
|
throw new Error("Server bundle not found in manifest. Run `pnpm build:server` to generate the server bundle.");
|
|
692
692
|
}
|
|
693
|
-
} else {
|
|
694
|
-
throw new Error("Server bundle not found in manifest. Run `pnpm build:server` to generate the server bundle.");
|
|
695
693
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
694
|
+
const { data: pageData, __context: context, __layouts: layouts } = data;
|
|
695
|
+
const componentName = viewComponent.displayName || viewComponent.name || "Component";
|
|
696
|
+
const inlineScripts = this.templateParser.buildInlineScripts(pageData, context, componentName, layouts);
|
|
697
|
+
const clientScript = this.templateParser.getClientScriptTag(this.isDevelopment, this.manifest);
|
|
698
|
+
const stylesheetTags = this.templateParser.getStylesheetTags(this.isDevelopment, this.manifest);
|
|
699
|
+
const headTags = this.templateParser.buildHeadTags(head);
|
|
700
|
+
let didError = false;
|
|
701
|
+
let shellErrorOccurred = false;
|
|
702
|
+
const { PassThrough } = await import('stream');
|
|
703
|
+
const reactStream = new PassThrough();
|
|
704
|
+
let allReadyFired = false;
|
|
705
|
+
const { pipe, abort } = renderModule.renderComponentStream(viewComponent, data, {
|
|
706
|
+
onShellReady: /* @__PURE__ */ __name(() => {
|
|
707
|
+
shellReadyTime = Date.now();
|
|
708
|
+
if (!res.headersSent) {
|
|
709
|
+
res.statusCode = didError ? 500 : 200;
|
|
710
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
711
|
+
}
|
|
712
|
+
let htmlStart = templateParts.htmlStart;
|
|
713
|
+
htmlStart = htmlStart.replace("<!--styles-->", stylesheetTags);
|
|
714
|
+
htmlStart = htmlStart.replace("<!--head-meta-->", headTags);
|
|
715
|
+
res.write(htmlStart);
|
|
716
|
+
res.write(templateParts.rootStart);
|
|
717
|
+
pipe(reactStream);
|
|
718
|
+
reactStream.pipe(res, {
|
|
719
|
+
end: false
|
|
720
|
+
});
|
|
721
|
+
if (this.isDevelopment) {
|
|
722
|
+
const ttfb = shellReadyTime - startTime;
|
|
723
|
+
this.logger.log(`[SSR] ${componentName} shell ready in ${ttfb}ms (stream mode - TTFB)`);
|
|
724
|
+
}
|
|
725
|
+
}, "onShellReady"),
|
|
726
|
+
onShellError: /* @__PURE__ */ __name((error) => {
|
|
727
|
+
shellErrorOccurred = true;
|
|
728
|
+
this.streamingErrorHandler.handleShellError(error, res, componentName, this.isDevelopment);
|
|
729
|
+
resolve();
|
|
730
|
+
}, "onShellError"),
|
|
731
|
+
onError: /* @__PURE__ */ __name((error) => {
|
|
732
|
+
didError = true;
|
|
733
|
+
this.streamingErrorHandler.handleStreamError(error, componentName);
|
|
734
|
+
}, "onError"),
|
|
735
|
+
onAllReady: /* @__PURE__ */ __name(() => {
|
|
736
|
+
allReadyFired = true;
|
|
737
|
+
}, "onAllReady")
|
|
738
|
+
});
|
|
739
|
+
reactStream.on("end", () => {
|
|
740
|
+
if (shellErrorOccurred) {
|
|
741
|
+
return;
|
|
718
742
|
}
|
|
719
|
-
}, "onShellReady"),
|
|
720
|
-
onShellError: /* @__PURE__ */ __name((error) => {
|
|
721
|
-
this.streamingErrorHandler.handleShellError(error, res, componentName, this.isDevelopment);
|
|
722
|
-
}, "onShellError"),
|
|
723
|
-
onError: /* @__PURE__ */ __name((error) => {
|
|
724
|
-
didError = true;
|
|
725
|
-
this.streamingErrorHandler.handleStreamError(error, componentName);
|
|
726
|
-
}, "onError"),
|
|
727
|
-
onAllReady: /* @__PURE__ */ __name(() => {
|
|
728
743
|
res.write(inlineScripts);
|
|
729
744
|
res.write(clientScript);
|
|
730
745
|
res.write(templateParts.rootEnd);
|
|
@@ -733,17 +748,25 @@ var RenderService = class _RenderService {
|
|
|
733
748
|
if (this.isDevelopment) {
|
|
734
749
|
const totalTime = Date.now() - startTime;
|
|
735
750
|
const streamTime = Date.now() - shellReadyTime;
|
|
736
|
-
|
|
751
|
+
const viaAllReady = allReadyFired ? " (onAllReady fired)" : " (onAllReady never fired)";
|
|
752
|
+
this.logger.log(`[SSR] ${componentName} streaming complete in ${totalTime}ms total (${streamTime}ms streaming)${viaAllReady}`);
|
|
737
753
|
}
|
|
738
|
-
|
|
754
|
+
resolve();
|
|
755
|
+
});
|
|
756
|
+
reactStream.on("error", (error) => {
|
|
757
|
+
reject(error);
|
|
758
|
+
});
|
|
759
|
+
res.on("close", () => {
|
|
760
|
+
abort();
|
|
761
|
+
resolve();
|
|
762
|
+
});
|
|
763
|
+
}, "executeStream");
|
|
764
|
+
executeStream().catch((error) => {
|
|
765
|
+
const componentName = typeof viewComponent === "function" ? viewComponent.name : String(viewComponent);
|
|
766
|
+
this.streamingErrorHandler.handleShellError(error, res, componentName, this.isDevelopment);
|
|
767
|
+
resolve();
|
|
739
768
|
});
|
|
740
|
-
|
|
741
|
-
abort();
|
|
742
|
-
});
|
|
743
|
-
} catch (error) {
|
|
744
|
-
const componentName = typeof viewComponent === "function" ? viewComponent.name : String(viewComponent);
|
|
745
|
-
this.streamingErrorHandler.handleShellError(error, res, componentName, this.isDevelopment);
|
|
746
|
-
}
|
|
769
|
+
});
|
|
747
770
|
}
|
|
748
771
|
};
|
|
749
772
|
RenderService = _ts_decorate3([
|
|
@@ -859,6 +882,9 @@ var RenderInterceptor = class {
|
|
|
859
882
|
const httpContext = context.switchToHttp();
|
|
860
883
|
const request = httpContext.getRequest();
|
|
861
884
|
const response = httpContext.getResponse();
|
|
885
|
+
if (typeof data === "string") {
|
|
886
|
+
return data;
|
|
887
|
+
}
|
|
862
888
|
const renderContext = {
|
|
863
889
|
url: request.url,
|
|
864
890
|
path: request.path,
|
|
@@ -949,11 +975,23 @@ var ViteInitializerService = class _ViteInitializerService {
|
|
|
949
975
|
viteMode;
|
|
950
976
|
vitePort;
|
|
951
977
|
viteServer = null;
|
|
978
|
+
isShuttingDown = false;
|
|
952
979
|
constructor(renderService, httpAdapterHost, viteConfig) {
|
|
953
980
|
this.renderService = renderService;
|
|
954
981
|
this.httpAdapterHost = httpAdapterHost;
|
|
955
982
|
this.viteMode = viteConfig?.mode || "embedded";
|
|
956
983
|
this.vitePort = viteConfig?.port || 5173;
|
|
984
|
+
this.registerSignalHandlers();
|
|
985
|
+
}
|
|
986
|
+
registerSignalHandlers() {
|
|
987
|
+
const cleanup = /* @__PURE__ */ __name(async (signal) => {
|
|
988
|
+
if (this.isShuttingDown) return;
|
|
989
|
+
this.isShuttingDown = true;
|
|
990
|
+
this.logger.log(`Received ${signal}, closing Vite server...`);
|
|
991
|
+
await this.closeViteServer();
|
|
992
|
+
}, "cleanup");
|
|
993
|
+
process.once("SIGTERM", () => cleanup("SIGTERM"));
|
|
994
|
+
process.once("SIGINT", () => cleanup("SIGINT"));
|
|
957
995
|
}
|
|
958
996
|
async onModuleInit() {
|
|
959
997
|
const isDevelopment = process.env.NODE_ENV !== "production";
|
|
@@ -1042,9 +1080,23 @@ var ViteInitializerService = class _ViteInitializerService {
|
|
|
1042
1080
|
* This prevents port conflicts on hot reload
|
|
1043
1081
|
*/
|
|
1044
1082
|
async onModuleDestroy() {
|
|
1083
|
+
await this.closeViteServer();
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Cleanup: Close Vite server on application shutdown
|
|
1087
|
+
* Belt-and-suspenders approach with onModuleDestroy
|
|
1088
|
+
*/
|
|
1089
|
+
async onApplicationShutdown() {
|
|
1090
|
+
await this.closeViteServer();
|
|
1091
|
+
}
|
|
1092
|
+
async closeViteServer() {
|
|
1093
|
+
if (this.isShuttingDown && !this.viteServer) return;
|
|
1094
|
+
this.isShuttingDown = true;
|
|
1045
1095
|
if (this.viteServer) {
|
|
1046
1096
|
try {
|
|
1097
|
+
this.renderService.setViteServer(null);
|
|
1047
1098
|
await this.viteServer.close();
|
|
1099
|
+
this.viteServer = null;
|
|
1048
1100
|
this.logger.log("\u2713 Vite server closed");
|
|
1049
1101
|
} catch (error) {
|
|
1050
1102
|
this.logger.warn(`Failed to close Vite server: ${error.message}`);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML head data for SEO and page metadata
|
|
3
|
+
*/
|
|
4
|
+
interface HeadData {
|
|
5
|
+
/** Page title (appears in browser tab and search results) */
|
|
6
|
+
title?: string;
|
|
7
|
+
/** Page description for search engines */
|
|
8
|
+
description?: string;
|
|
9
|
+
/** Page keywords (legacy, less important for modern SEO) */
|
|
10
|
+
keywords?: string;
|
|
11
|
+
/** Canonical URL for duplicate content */
|
|
12
|
+
canonical?: string;
|
|
13
|
+
/** Open Graph title for social media sharing */
|
|
14
|
+
ogTitle?: string;
|
|
15
|
+
/** Open Graph description for social media sharing */
|
|
16
|
+
ogDescription?: string;
|
|
17
|
+
/** Open Graph image URL for social media previews */
|
|
18
|
+
ogImage?: string;
|
|
19
|
+
/** Additional link tags (fonts, icons, preloads, etc.) */
|
|
20
|
+
links?: Array<{
|
|
21
|
+
rel: string;
|
|
22
|
+
href: string;
|
|
23
|
+
as?: string;
|
|
24
|
+
type?: string;
|
|
25
|
+
crossorigin?: string;
|
|
26
|
+
[key: string]: any;
|
|
27
|
+
}>;
|
|
28
|
+
/** Additional meta tags */
|
|
29
|
+
meta?: Array<{
|
|
30
|
+
name?: string;
|
|
31
|
+
property?: string;
|
|
32
|
+
content: string;
|
|
33
|
+
[key: string]: any;
|
|
34
|
+
}>;
|
|
35
|
+
/** Script tags for analytics, tracking, etc. */
|
|
36
|
+
scripts?: Array<{
|
|
37
|
+
src?: string;
|
|
38
|
+
async?: boolean;
|
|
39
|
+
defer?: boolean;
|
|
40
|
+
type?: string;
|
|
41
|
+
innerHTML?: string;
|
|
42
|
+
[key: string]: any;
|
|
43
|
+
}>;
|
|
44
|
+
/** JSON-LD structured data for search engines */
|
|
45
|
+
jsonLd?: Array<Record<string, any>>;
|
|
46
|
+
/** Attributes to add to <html> tag (e.g., lang, dir) */
|
|
47
|
+
htmlAttributes?: Record<string, string>;
|
|
48
|
+
/** Attributes to add to <body> tag (e.g., class, data-theme) */
|
|
49
|
+
bodyAttributes?: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Response structure for SSR rendering
|
|
53
|
+
*
|
|
54
|
+
* Can be returned from controllers decorated with @Render.
|
|
55
|
+
* For backwards compatibility, controllers can also return plain objects
|
|
56
|
+
* which will be auto-wrapped as { props: data }.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* // Simple case - just props (auto-wrapped)
|
|
61
|
+
* @Render('views/home')
|
|
62
|
+
* getHome() {
|
|
63
|
+
* return { message: 'Hello' };
|
|
64
|
+
* // Treated as: { props: { message: 'Hello' } }
|
|
65
|
+
* }
|
|
66
|
+
*
|
|
67
|
+
* // Advanced case - with head data and layout props
|
|
68
|
+
* @Render('views/user')
|
|
69
|
+
* getUser(@Param('id') id: string) {
|
|
70
|
+
* const user = await this.userService.findOne(id);
|
|
71
|
+
* return {
|
|
72
|
+
* props: { user },
|
|
73
|
+
* layoutProps: {
|
|
74
|
+
* title: user.name,
|
|
75
|
+
* subtitle: 'User Profile'
|
|
76
|
+
* },
|
|
77
|
+
* head: {
|
|
78
|
+
* title: `${user.name} - Profile`,
|
|
79
|
+
* description: user.bio,
|
|
80
|
+
* ogImage: user.avatar
|
|
81
|
+
* }
|
|
82
|
+
* };
|
|
83
|
+
* }
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
interface RenderResponse<T = any> {
|
|
87
|
+
/** Props passed to the React component */
|
|
88
|
+
props: T;
|
|
89
|
+
/** HTML head data (title, meta tags, links) */
|
|
90
|
+
head?: HeadData;
|
|
91
|
+
/**
|
|
92
|
+
* Props passed to layout components (dynamic, per-request)
|
|
93
|
+
*
|
|
94
|
+
* These props are merged with static layout props from decorators:
|
|
95
|
+
* - Static props from @Layout decorator (controller level)
|
|
96
|
+
* - Static props from @Render decorator (method level)
|
|
97
|
+
* - Dynamic props from this field (highest priority)
|
|
98
|
+
*
|
|
99
|
+
* All layout components in the hierarchy receive the merged props.
|
|
100
|
+
*/
|
|
101
|
+
layoutProps?: Record<string, any>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type { HeadData as H, RenderResponse as R };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML head data for SEO and page metadata
|
|
3
|
+
*/
|
|
4
|
+
interface HeadData {
|
|
5
|
+
/** Page title (appears in browser tab and search results) */
|
|
6
|
+
title?: string;
|
|
7
|
+
/** Page description for search engines */
|
|
8
|
+
description?: string;
|
|
9
|
+
/** Page keywords (legacy, less important for modern SEO) */
|
|
10
|
+
keywords?: string;
|
|
11
|
+
/** Canonical URL for duplicate content */
|
|
12
|
+
canonical?: string;
|
|
13
|
+
/** Open Graph title for social media sharing */
|
|
14
|
+
ogTitle?: string;
|
|
15
|
+
/** Open Graph description for social media sharing */
|
|
16
|
+
ogDescription?: string;
|
|
17
|
+
/** Open Graph image URL for social media previews */
|
|
18
|
+
ogImage?: string;
|
|
19
|
+
/** Additional link tags (fonts, icons, preloads, etc.) */
|
|
20
|
+
links?: Array<{
|
|
21
|
+
rel: string;
|
|
22
|
+
href: string;
|
|
23
|
+
as?: string;
|
|
24
|
+
type?: string;
|
|
25
|
+
crossorigin?: string;
|
|
26
|
+
[key: string]: any;
|
|
27
|
+
}>;
|
|
28
|
+
/** Additional meta tags */
|
|
29
|
+
meta?: Array<{
|
|
30
|
+
name?: string;
|
|
31
|
+
property?: string;
|
|
32
|
+
content: string;
|
|
33
|
+
[key: string]: any;
|
|
34
|
+
}>;
|
|
35
|
+
/** Script tags for analytics, tracking, etc. */
|
|
36
|
+
scripts?: Array<{
|
|
37
|
+
src?: string;
|
|
38
|
+
async?: boolean;
|
|
39
|
+
defer?: boolean;
|
|
40
|
+
type?: string;
|
|
41
|
+
innerHTML?: string;
|
|
42
|
+
[key: string]: any;
|
|
43
|
+
}>;
|
|
44
|
+
/** JSON-LD structured data for search engines */
|
|
45
|
+
jsonLd?: Array<Record<string, any>>;
|
|
46
|
+
/** Attributes to add to <html> tag (e.g., lang, dir) */
|
|
47
|
+
htmlAttributes?: Record<string, string>;
|
|
48
|
+
/** Attributes to add to <body> tag (e.g., class, data-theme) */
|
|
49
|
+
bodyAttributes?: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Response structure for SSR rendering
|
|
53
|
+
*
|
|
54
|
+
* Can be returned from controllers decorated with @Render.
|
|
55
|
+
* For backwards compatibility, controllers can also return plain objects
|
|
56
|
+
* which will be auto-wrapped as { props: data }.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* // Simple case - just props (auto-wrapped)
|
|
61
|
+
* @Render('views/home')
|
|
62
|
+
* getHome() {
|
|
63
|
+
* return { message: 'Hello' };
|
|
64
|
+
* // Treated as: { props: { message: 'Hello' } }
|
|
65
|
+
* }
|
|
66
|
+
*
|
|
67
|
+
* // Advanced case - with head data and layout props
|
|
68
|
+
* @Render('views/user')
|
|
69
|
+
* getUser(@Param('id') id: string) {
|
|
70
|
+
* const user = await this.userService.findOne(id);
|
|
71
|
+
* return {
|
|
72
|
+
* props: { user },
|
|
73
|
+
* layoutProps: {
|
|
74
|
+
* title: user.name,
|
|
75
|
+
* subtitle: 'User Profile'
|
|
76
|
+
* },
|
|
77
|
+
* head: {
|
|
78
|
+
* title: `${user.name} - Profile`,
|
|
79
|
+
* description: user.bio,
|
|
80
|
+
* ogImage: user.avatar
|
|
81
|
+
* }
|
|
82
|
+
* };
|
|
83
|
+
* }
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
interface RenderResponse<T = any> {
|
|
87
|
+
/** Props passed to the React component */
|
|
88
|
+
props: T;
|
|
89
|
+
/** HTML head data (title, meta tags, links) */
|
|
90
|
+
head?: HeadData;
|
|
91
|
+
/**
|
|
92
|
+
* Props passed to layout components (dynamic, per-request)
|
|
93
|
+
*
|
|
94
|
+
* These props are merged with static layout props from decorators:
|
|
95
|
+
* - Static props from @Layout decorator (controller level)
|
|
96
|
+
* - Static props from @Render decorator (method level)
|
|
97
|
+
* - Dynamic props from this field (highest priority)
|
|
98
|
+
*
|
|
99
|
+
* All layout components in the hierarchy receive the merged props.
|
|
100
|
+
*/
|
|
101
|
+
layoutProps?: Record<string, any>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type { HeadData as H, RenderResponse as R };
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
/// <reference
|
|
1
|
+
/// <reference types="@nestjs-ssr/react/global" />
|
|
2
2
|
import React, { StrictMode } from 'react';
|
|
3
3
|
import { hydrateRoot } from 'react-dom/client';
|
|
4
|
-
import { PageContextProvider } from '
|
|
4
|
+
import { PageContextProvider } from '@nestjs-ssr/react/client';
|
|
5
5
|
|
|
6
6
|
const componentName = window.__COMPONENT_NAME__;
|
|
7
7
|
const initialProps = window.__INITIAL_STATE__ || {};
|
|
8
8
|
const renderContext = window.__CONTEXT__ || {};
|
|
9
9
|
|
|
10
10
|
// Auto-import all view components using Vite's glob feature
|
|
11
|
+
// Exclude entry-client.tsx and entry-server.tsx from the glob
|
|
11
12
|
// @ts-ignore - Vite-specific API
|
|
12
13
|
const modules: Record<string, { default: React.ComponentType<any> }> =
|
|
13
|
-
import.meta.glob('@/views/**/*.tsx', {
|
|
14
|
+
import.meta.glob(['@/views/**/*.tsx', '!@/views/entry-*.tsx'], {
|
|
15
|
+
eager: true,
|
|
16
|
+
});
|
|
14
17
|
|
|
15
18
|
// Build a map of components with their metadata
|
|
16
19
|
// Filter out entry files and modules without default exports
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { renderToString } from 'react-dom/server';
|
|
3
|
-
import { PageContextProvider } from '
|
|
2
|
+
import { renderToString, renderToPipeableStream } from 'react-dom/server';
|
|
3
|
+
import { PageContextProvider } from '@nestjs-ssr/react';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Compose a component with its layouts from the interceptor
|
|
@@ -23,6 +23,10 @@ function composeWithLayouts(
|
|
|
23
23
|
return result;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* String-based SSR (mode: 'string')
|
|
28
|
+
* Simple, synchronous rendering
|
|
29
|
+
*/
|
|
26
30
|
export function renderComponent(
|
|
27
31
|
ViewComponent: React.ComponentType<any>,
|
|
28
32
|
data: any,
|
|
@@ -39,3 +43,30 @@ export function renderComponent(
|
|
|
39
43
|
|
|
40
44
|
return renderToString(wrappedElement);
|
|
41
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Streaming SSR (mode: 'stream' - default)
|
|
49
|
+
* Modern approach with progressive rendering and Suspense support
|
|
50
|
+
*/
|
|
51
|
+
export function renderComponentStream(
|
|
52
|
+
ViewComponent: React.ComponentType<any>,
|
|
53
|
+
data: any,
|
|
54
|
+
callbacks?: {
|
|
55
|
+
onShellReady?: () => void;
|
|
56
|
+
onShellError?: (error: unknown) => void;
|
|
57
|
+
onError?: (error: unknown) => void;
|
|
58
|
+
onAllReady?: () => void;
|
|
59
|
+
},
|
|
60
|
+
) {
|
|
61
|
+
const { data: pageData, __context: context, __layouts: layouts } = data;
|
|
62
|
+
const composedElement = composeWithLayouts(ViewComponent, pageData, layouts);
|
|
63
|
+
|
|
64
|
+
// Wrap with PageContextProvider to make context available via hooks
|
|
65
|
+
const wrappedElement = (
|
|
66
|
+
<PageContextProvider context={context}>
|
|
67
|
+
{composedElement}
|
|
68
|
+
</PageContextProvider>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return renderToPipeableStream(wrappedElement, callbacks);
|
|
72
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<!--head-meta-->
|
|
8
|
+
<!--styles-->
|
|
9
|
+
</head>
|
|
10
|
+
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"><!--app-html--></div>
|
|
13
|
+
<!--initial-state-->
|
|
14
|
+
<!--client-scripts-->
|
|
15
|
+
</body>
|
|
16
|
+
|
|
17
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestjs-ssr/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "React SSR for NestJS that respects Clean Architecture. Proper DI, SOLID principles, clear separation of concerns.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nestjs",
|
|
@@ -47,6 +47,11 @@
|
|
|
47
47
|
"import": "./dist/index.mjs",
|
|
48
48
|
"require": "./dist/index.js"
|
|
49
49
|
},
|
|
50
|
+
"./client": {
|
|
51
|
+
"types": "./dist/client.d.ts",
|
|
52
|
+
"import": "./dist/client.mjs",
|
|
53
|
+
"require": "./dist/client.js"
|
|
54
|
+
},
|
|
50
55
|
"./render": {
|
|
51
56
|
"types": "./dist/render/index.d.ts",
|
|
52
57
|
"import": "./dist/render/index.mjs",
|
|
@@ -79,6 +84,12 @@
|
|
|
79
84
|
"test:watch": "vitest",
|
|
80
85
|
"test:ui": "vitest --ui",
|
|
81
86
|
"test:coverage": "vitest run --coverage",
|
|
87
|
+
"test:integration": "pnpm test:integration:setup && pnpm test:integration:dev && pnpm test:integration:prod",
|
|
88
|
+
"test:integration:setup": "tsx test/integration/setup/create-fixtures.ts",
|
|
89
|
+
"test:integration:dev": "TEST_MODE=dev playwright test -c test/integration/playwright.config.ts",
|
|
90
|
+
"test:integration:prod": "TEST_MODE=prod playwright test -c test/integration/playwright.config.ts",
|
|
91
|
+
"test:integration:run": "pnpm test:integration:dev",
|
|
92
|
+
"test:integration:clean": "rm -rf test/integration/fixtures/*/",
|
|
82
93
|
"size": "size-limit",
|
|
83
94
|
"api:extract": "api-extractor run --local --verbose",
|
|
84
95
|
"api:check": "api-extractor run --verbose",
|
|
@@ -120,6 +131,7 @@
|
|
|
120
131
|
},
|
|
121
132
|
"devDependencies": {
|
|
122
133
|
"@microsoft/api-extractor": "^7.55.2",
|
|
134
|
+
"@playwright/test": "^1.49.0",
|
|
123
135
|
"@nestjs/common": "^11.0.0",
|
|
124
136
|
"@nestjs/core": "^11.0.0",
|
|
125
137
|
"@nestjs/platform-express": "^11.0.0",
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
/// <reference
|
|
1
|
+
/// <reference types="@nestjs-ssr/react/global" />
|
|
2
2
|
import React, { StrictMode } from 'react';
|
|
3
3
|
import { hydrateRoot } from 'react-dom/client';
|
|
4
|
-
import { PageContextProvider } from '
|
|
4
|
+
import { PageContextProvider } from '@nestjs-ssr/react/client';
|
|
5
5
|
|
|
6
6
|
const componentName = window.__COMPONENT_NAME__;
|
|
7
7
|
const initialProps = window.__INITIAL_STATE__ || {};
|
|
8
8
|
const renderContext = window.__CONTEXT__ || {};
|
|
9
9
|
|
|
10
10
|
// Auto-import all view components using Vite's glob feature
|
|
11
|
+
// Exclude entry-client.tsx and entry-server.tsx from the glob
|
|
11
12
|
// @ts-ignore - Vite-specific API
|
|
12
13
|
const modules: Record<string, { default: React.ComponentType<any> }> =
|
|
13
|
-
import.meta.glob('@/views/**/*.tsx', {
|
|
14
|
+
import.meta.glob(['@/views/**/*.tsx', '!@/views/entry-*.tsx'], {
|
|
15
|
+
eager: true,
|
|
16
|
+
});
|
|
14
17
|
|
|
15
18
|
// Build a map of components with their metadata
|
|
16
19
|
// Filter out entry files and modules without default exports
|