@canonical/react-ssr 0.4.0-experimental.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.md ADDED
@@ -0,0 +1,152 @@
1
+ # Canonical React SSR: No-Hassle Server-Side Rendering for React
2
+
3
+ This guide demonstrates how to set up server-side rendering (SSR) for React applications using `@canonical/react-ssr`. It covers everything from installation to building and handling SSR requests with client and server entry points.
4
+
5
+ ## Table of Contents
6
+ 1. [Installation](#installation)
7
+ 2. [Quick Start](#quick-start)
8
+ - [Server-Side Entry Point](#server-side-entry-point)
9
+ - [Client-Side Entry Point](#client-side-entry-point)
10
+ - [Building Your Application](#building-your-application)
11
+ - [Server Request Handling](#server-request-handling)
12
+ - [Injecting the Client Application](#injecting-the-client-application)
13
+ 3. [Customization](#customization)
14
+ - [Bootstrap Scripts](#bootstrap-scripts)
15
+
16
+ ---
17
+
18
+ ## Installation
19
+
20
+ First, install the `@canonical/react-ssr` package:
21
+
22
+ ```bash
23
+ npm install @canonical/react-ssr
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ This section walks you through setting up SSR for your React app, including creating entry points, building your app, and handling SSR requests.
29
+
30
+ ### Entrypoints
31
+
32
+ You will notice that this setup encourages two entry points: one for the server, and one for the client.
33
+ The server entry point includes the full application HTML for compatibility with streams.
34
+ The client entry point includes just the application component, which is hydrated on the client.
35
+
36
+ ### Server-Side Entry Point
37
+
38
+ Create a server-side entry point to wrap your application and inject the necessary scripts and links into the HTML.
39
+
40
+ ```tsx
41
+ // src/ssr/entry-server.tsx
42
+ import Application from "../Application.js";
43
+ import React from "react";
44
+ import type {ReactServerEntrypointComponent, RendererServerEntrypointProps} from "@canonical/react-ssr/renderer";
45
+
46
+ // Define your server-side entry point component
47
+ const EntryServer: ReactServerEntrypointComponent<RendererServerEntrypointProps> = ({ lang = "en", scriptTags, linkTags }) => (
48
+ <html lang={lang}>
49
+ <head>
50
+ <title>Canonical React SSR</title>
51
+ {scriptTags}
52
+ {linkTags}
53
+ </head>
54
+ <body>
55
+ <div id="root">
56
+ <Application />
57
+ </div>
58
+ </body>
59
+ </html>
60
+ );
61
+
62
+ export default EntryServer;
63
+ ```
64
+ This component is responsible for rendering the HTML structure and injecting the necessary script and link tags that are required for hydration on the client.
65
+
66
+ ### Client-Side Entry Point
67
+ Set up client-side hydration to rehydrate your app after the SSR content has been rendered.
68
+ ```tsx
69
+ // src/ssr/entry-client.tsx
70
+ import { hydrateRoot } from "react-dom/client";
71
+ import Application from "../Application.js";
72
+
73
+ // Hydrate the client-side React app after the server-rendered HTML is loaded
74
+ hydrateRoot(document.getElementById("root") as HTMLElement, <Application />);
75
+ ```
76
+
77
+ ### Building your application
78
+ To build your SSR React app, use a tool like Vite, Webpack, or Next.
79
+ The build process should include both client and server bundles. First, build the client-side app, then the server-side entry point.
80
+ The example below uses Vite.
81
+
82
+ ```json5
83
+ // package.json
84
+ {
85
+ "scripts": {
86
+ "build": "bun run build:client && bun run build:server",
87
+ "build:client": "vite build --ssrManifest --outDir dist/client",
88
+ "build:server": "vite build --ssr src/ssr/server.ts --outDir dist/server"
89
+ }
90
+ }
91
+
92
+ ```
93
+
94
+ ### Server Request Handling
95
+
96
+ Once your app is built, you can set up an Express server to handle SSR requests.
97
+ See [this file](../../../apps/boilerplate-react-vite/src/ssr/server.ts) as an example.
98
+
99
+ ### Injecting the Client Application
100
+
101
+ Your client-side entry point must be executed by the client upon page load to rehydrate the server-rendered app.
102
+
103
+ Example for injecting the client application into your HTML with Vite:
104
+
105
+ ```html
106
+ <html lang="en">
107
+ <head>
108
+ <meta charset="UTF-8" />
109
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
110
+ <title>Vite + React + TS</title>
111
+ </head>
112
+ <body>
113
+ <div id="root"></div>
114
+ <!-- Inject the client-side entry point -->
115
+ <script type="module" src="/src/ssr/entry-client.tsx"></script>
116
+ </body>
117
+ </html>
118
+ ```
119
+ This script will hydrate the app on the client, connecting the React app to the server-rendered HTML.
120
+
121
+ #### Customization
122
+ You can inject additional bootstrapping scripts to customize the client-side setup.
123
+ This is useful if you need more control over how the client app boots.
124
+
125
+ ##### Bootstrap Scripts
126
+ You can pass custom modules, scripts, or inline script content to be executed on the client before the app is hydrated.
127
+
128
+ ###### Options
129
+ - `bootstrapModules`: An array of module paths. Generates `<script type="module" src="{SCRIPT_LINK}"></script>` elements.
130
+ - `bootstrapScripts`: An array of script paths. Generates `<script type="text/javascript" src="{SCRIPT_LINK}"></script>` elements.
131
+ - `bootstrapScriptContent`: Raw script content. Generates `<script type="text/javascript">{SCRIPT_CONTENT}</script>` elements.
132
+
133
+ ```ts
134
+ import { JSXRenderer } from "@canonical/react-ssr/renderer";
135
+ // Pass custom bootstrap scripts to the renderer
136
+ const Renderer = new JSXRenderer(
137
+ EntryServer,
138
+ {
139
+ htmlString,
140
+ renderToPipeableStreamOptions: {
141
+ bootstrapModules: ["src/ssr/entry-client.tsx"] // Adds the client-side entry module to the page
142
+ }
143
+ }
144
+ );
145
+ ```
146
+
147
+ The `JSXRenderer` also accepts `renderToPipeableStreamOptions`, which are passed to react-dom/server`'s `renderToPipeableStream()`.
148
+
149
+ For further information, refer to [React's `renderToPipeableStream()` documentation](https://react.dev/reference/react-dom/server/renderToPipeableStream#parameters).
150
+
151
+
152
+
@@ -0,0 +1,3 @@
1
+ export * as renderer from "./renderer/index.js";
2
+ export * as server from "./server/index.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC;AAChD,OAAO,KAAK,MAAM,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,63 @@
1
+ import { casing } from "@canonical/utils";
2
+ import { parseDocument } from "htmlparser2";
3
+ import React from "react";
4
+ /**
5
+ * Parses an HTML string to extract and convert script and link tags to React.createElement calls.
6
+ */
7
+ class Extractor {
8
+ // biome-ignore lint/suspicious/noExplicitAny: explicit any needed for the document type
9
+ document;
10
+ constructor(html) {
11
+ this.document = parseDocument(html);
12
+ }
13
+ getElementsByTagName(tagName) {
14
+ const elements = [];
15
+ const stack = [this.document];
16
+ while (stack.length) {
17
+ const node = stack.pop();
18
+ if (!node)
19
+ continue;
20
+ if (node.type === "tag" && node.name === tagName) {
21
+ elements.push(node);
22
+ }
23
+ // Check for script tags specifically
24
+ if (node.type === "script" && tagName === "script") {
25
+ elements.push(node);
26
+ }
27
+ if (node.children) {
28
+ stack.push(...node.children);
29
+ }
30
+ }
31
+ console.log(`Found ${elements.length} <${tagName}> tags`);
32
+ return elements;
33
+ }
34
+ convertKeyToReactKey(key) {
35
+ switch (key.toLowerCase()) {
36
+ case "class":
37
+ return "className";
38
+ case "for":
39
+ return "htmlFor";
40
+ case "crossorigin":
41
+ return "crossOrigin";
42
+ default:
43
+ return casing.toCamelCase(key);
44
+ }
45
+ }
46
+ convertToReactElement(element) {
47
+ const props = {};
48
+ for (const [key, value] of Object.entries(element.attribs)) {
49
+ props[this.convertKeyToReactKey(key)] = value;
50
+ }
51
+ return React.createElement(element.name, props);
52
+ }
53
+ getLinkTags() {
54
+ const linkElements = this.getElementsByTagName("link");
55
+ return linkElements.map(this.convertToReactElement, this);
56
+ }
57
+ getScriptTags() {
58
+ const scriptElements = this.getElementsByTagName("script");
59
+ return scriptElements.map(this.convertToReactElement, this);
60
+ }
61
+ }
62
+ export default Extractor;
63
+ //# sourceMappingURL=Extractor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Extractor.js","sourceRoot":"","sources":["../../../src/renderer/Extractor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE1C,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B;;GAEG;AACH,MAAM,SAAS;IACb,wFAAwF;IACvE,QAAQ,CAAM;IAE/B,YAAY,IAAY;QACtB,IAAI,CAAC,QAAQ,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC;IAEO,oBAAoB,CAAC,OAAe;QAC1C,MAAM,QAAQ,GAAc,EAAE,CAAC;QAC/B,MAAM,KAAK,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAE9B,OAAO,KAAK,CAAC,MAAM,EAAE,CAAC;YACpB,MAAM,IAAI,GAAG,KAAK,CAAC,GAAG,EAAE,CAAC;YACzB,IAAI,CAAC,IAAI;gBAAE,SAAS;YAEpB,IAAI,IAAI,CAAC,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBACjD,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACtB,CAAC;YAED,qCAAqC;YACrC,IAAI,IAAI,CAAC,IAAI,KAAK,QAAQ,IAAI,OAAO,KAAK,QAAQ,EAAE,CAAC;gBACnD,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACtB,CAAC;YAED,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;gBAClB,KAAK,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC/B,CAAC;QACH,CAAC;QAED,OAAO,CAAC,GAAG,CAAC,SAAS,QAAQ,CAAC,MAAM,KAAK,OAAO,QAAQ,CAAC,CAAC;QAC1D,OAAO,QAAQ,CAAC;IAClB,CAAC;IAEO,oBAAoB,CAAC,GAAW;QACtC,QAAQ,GAAG,CAAC,WAAW,EAAE,EAAE,CAAC;YAC1B,KAAK,OAAO;gBACV,OAAO,WAAW,CAAC;YACrB,KAAK,KAAK;gBACR,OAAO,SAAS,CAAC;YACnB,KAAK,aAAa;gBAChB,OAAO,aAAa,CAAC;YACvB;gBACE,OAAO,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;QACnC,CAAC;IACH,CAAC;IAEO,qBAAqB,CAAC,OAAgB;QAC5C,MAAM,KAAK,GAA8B,EAAE,CAAC;QAE5C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;YAC3D,KAAK,CAAC,IAAI,CAAC,oBAAoB,CAAC,GAAG,CAAC,CAAC,GAAG,KAAK,CAAC;QAChD,CAAC;QAED,OAAO,KAAK,CAAC,aAAa,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAClD,CAAC;IAEM,WAAW;QAChB,MAAM,YAAY,GAAG,IAAI,CAAC,oBAAoB,CAAC,MAAM,CAAC,CAAC;QACvD,OAAO,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,qBAAqB,EAAE,IAAI,CAAC,CAAC;IAC5D,CAAC;IAEM,aAAa;QAClB,MAAM,cAAc,GAAG,IAAI,CAAC,oBAAoB,CAAC,QAAQ,CAAC,CAAC;QAC3D,OAAO,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,qBAAqB,EAAE,IAAI,CAAC,CAAC;IAC9D,CAAC;CACF;AAED,eAAe,SAAS,CAAC"}
@@ -0,0 +1,79 @@
1
+ import { createElement } from "react";
2
+ import { renderToPipeableStream, } from "react-dom/server";
3
+ import Extractor from "./Extractor.js";
4
+ // This class is responsible for rendering a React component to a readable stream.
5
+ export default class Renderer {
6
+ Component;
7
+ options;
8
+ locale;
9
+ // private messages: any;
10
+ extractor;
11
+ constructor(Component, options = {}) {
12
+ this.Component = Component;
13
+ this.options = options;
14
+ // this.prepareLocale = this.prepareLocale.bind(this);
15
+ this.render = this.render.bind(this);
16
+ this.extractor = this.options.htmlString
17
+ ? new Extractor(this.options.htmlString)
18
+ : undefined;
19
+ }
20
+ //async prepareLocale(header: string | undefined) {
21
+ // if (this.options.loadMessages) {
22
+ // this.locale = header
23
+ // ? header.slice(0, 2)
24
+ // : this.options.defaultLocale || "en";
25
+ // //this.messages = await this.options.loadMessages(this.locale);
26
+ // }
27
+ //}
28
+ /**
29
+ * Gets the props needed to render the component
30
+ * @return The props needed to render the component
31
+ * @private
32
+ */
33
+ getComponentProps() {
34
+ return {
35
+ lang: this.locale,
36
+ scriptTags: this.extractor?.getScriptTags(),
37
+ linkTags: this.extractor?.getLinkTags(),
38
+ // todo implement message passing
39
+ // messages: this.messages,
40
+ };
41
+ }
42
+ /**
43
+ * Renders this renderer's JSX component as a transmittable stream and sends it to the client
44
+ * TODO add a render function for ReadableStream, and rename this to be focused on PipeableStream
45
+ * @param req Client's request
46
+ * @param res Response object that will be sent to the client
47
+ * @return {RenderResult} The stream that was sent to the client
48
+ */
49
+ render = (req, res) => {
50
+ // await this.prepareLocale(req.headers.get("accept-language") || undefined);
51
+ const jsx = createElement(this.Component, this.getComponentProps());
52
+ let renderingError;
53
+ const jsxStream = renderToPipeableStream(jsx, {
54
+ ...this.options.renderToPipeableStreamOptions,
55
+ // Early error, before the shell is prepared
56
+ onShellError() {
57
+ res
58
+ .writeHead(500, { "Content-Type": "text/html; charset=utf-8" })
59
+ .end("<h1>Something went wrong</h1>");
60
+ throw new Error("An error occurred while constructing the SSR shell");
61
+ },
62
+ onShellReady() {
63
+ res.writeHead(renderingError ? 500 : 200, {
64
+ "Content-Type": "text/html; charset=utf-8",
65
+ });
66
+ jsxStream.pipe(res);
67
+ res.on("finish", () => {
68
+ res.end();
69
+ });
70
+ },
71
+ // Error occurred during rendering, after the shell & headers were sent - store the error for usage after stream is sent
72
+ onError(error) {
73
+ renderingError = error;
74
+ },
75
+ });
76
+ return jsxStream;
77
+ };
78
+ }
79
+ //# sourceMappingURL=JSXRenderer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"JSXRenderer.js","sourceRoot":"","sources":["../../../src/renderer/JSXRenderer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAqB,aAAa,EAAE,MAAM,OAAO,CAAC;AACzD,OAAO,EAGL,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,SAAS,MAAM,gBAAgB,CAAC;AAyCvC,kFAAkF;AAClF,MAAM,CAAC,OAAO,OAAO,QAAQ;IAUR;IACA;IANX,MAAM,CAAqB;IACnC,yBAAyB;IACjB,SAAS,CAAwB;IAEzC,YACmB,SAAqB,EACrB,UAA2B,EAAE;QAD7B,cAAS,GAAT,SAAS,CAAY;QACrB,YAAO,GAAP,OAAO,CAAsB;QAE9C,sDAAsD;QACtD,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACrC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU;YACtC,CAAC,CAAC,IAAI,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC;YACxC,CAAC,CAAC,SAAS,CAAC;IAChB,CAAC;IAED,mDAAmD;IACnD,oCAAoC;IACpC,0BAA0B;IAC1B,4BAA4B;IAC5B,6CAA6C;IAC7C,qEAAqE;IACrE,KAAK;IACL,GAAG;IAEH;;;;OAIG;IACK,iBAAiB;QACvB,OAAO;YACL,IAAI,EAAE,IAAI,CAAC,MAAM;YACjB,UAAU,EAAE,IAAI,CAAC,SAAS,EAAE,aAAa,EAAE;YAC3C,QAAQ,EAAE,IAAI,CAAC,SAAS,EAAE,WAAW,EAAE;YACvC,iCAAiC;YACjC,2BAA2B;SACT,CAAC;IACvB,CAAC;IAED;;;;;;OAMG;IACH,MAAM,GAAkB,CACtB,GAAoB,EACpB,GAAmB,EACL,EAAE;QAChB,6EAA6E;QAC7E,MAAM,GAAG,GAAG,aAAa,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,iBAAiB,EAAE,CAAC,CAAC;QAEpE,IAAI,cAAqB,CAAC;QAE1B,MAAM,SAAS,GAAG,sBAAsB,CAAC,GAAG,EAAE;YAC5C,GAAG,IAAI,CAAC,OAAO,CAAC,6BAA6B;YAC7C,4CAA4C;YAC5C,YAAY;gBACV,GAAG;qBACA,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,0BAA0B,EAAE,CAAC;qBAC9D,GAAG,CAAC,+BAA+B,CAAC,CAAC;gBAExC,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;YACxE,CAAC;YACD,YAAY;gBACV,GAAG,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE;oBACxC,cAAc,EAAE,0BAA0B;iBAC3C,CAAC,CAAC;gBAEH,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;gBAEpB,GAAG,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE;oBACpB,GAAG,CAAC,GAAG,EAAE,CAAC;gBACZ,CAAC,CAAC,CAAC;YACL,CAAC;YACD,wHAAwH;YACxH,OAAO,CAAC,KAAK;gBACX,cAAc,GAAG,KAAc,CAAC;YAClC,CAAC;SACF,CAAC,CAAC;QACH,OAAO,SAAS,CAAC;IACnB,CAAC,CAAC;CACH"}
@@ -0,0 +1,3 @@
1
+ export { default as JSXRenderer } from "./JSXRenderer.js";
2
+ export { default as Extractor } from "./Extractor.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/renderer/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAQ1D,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export * from "./serve.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/server/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC"}
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import express from "express";
5
+ import { serveStream } from "./serve.js";
6
+ const { values, positionals } = parseArgs({
7
+ args: process.argv.slice(2),
8
+ options: {
9
+ port: {
10
+ type: "string",
11
+ alias: "p",
12
+ default: "5173",
13
+ },
14
+ staticFilepath: {
15
+ type: "string",
16
+ alias: "s",
17
+ default: "dist/client/assets",
18
+ },
19
+ staticRoute: {
20
+ type: "string",
21
+ alias: "r",
22
+ default: "assets",
23
+ },
24
+ },
25
+ strict: true,
26
+ allowPositionals: true,
27
+ });
28
+ const port = values.port || 5173;
29
+ const cwd = process.cwd();
30
+ const rendererFilePath = path.join(cwd, positionals[0]);
31
+ const staticDir = path.join(cwd, values.staticFilepath || "assets");
32
+ const staticRoute = values.staticRoute || "assets";
33
+ if (!rendererFilePath) {
34
+ console.error("Usage: node server.js <renderer-path>");
35
+ process.exit(1);
36
+ }
37
+ const handler = await import(rendererFilePath).then((module) => module.default);
38
+ if (typeof handler !== "function") {
39
+ throw new Error("Renderer file must default-export a renderer function.");
40
+ }
41
+ const app = express();
42
+ app.use(`/${staticRoute}`, express.static(staticDir));
43
+ app.use(serveStream(handler));
44
+ app.listen(port, () => {
45
+ console.log(`Server started on http://localhost:${port}/`);
46
+ });
47
+ //# sourceMappingURL=serve-express.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve-express.js","sourceRoot":"","sources":["../../../src/server/serve-express.ts"],"names":[],"mappings":";AAEA,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,OAAO,MAAM,SAAS,CAAC;AAE9B,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEzC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,SAAS,CAAC;IACxC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3B,OAAO,EAAE;QACP,IAAI,EAAE;YACJ,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,GAAG;YACV,OAAO,EAAE,MAAM;SAChB;QACD,cAAc,EAAE;YACd,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,GAAG;YACV,OAAO,EAAE,oBAAoB;SAC9B;QACD,WAAW,EAAE;YACX,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,GAAG;YACV,OAAO,EAAE,QAAQ;SAClB;KACF;IACD,MAAM,EAAE,IAAI;IACZ,gBAAgB,EAAE,IAAI;CACvB,CAAC,CAAC;AAEH,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,IAAI,IAAI,CAAC;AACjC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;AAC1B,MAAM,gBAAgB,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;AACxD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,IAAI,QAAQ,CAAC,CAAC;AACpE,MAAM,WAAW,GAAG,MAAM,CAAC,WAAW,IAAI,QAAQ,CAAC;AAEnD,IAAI,CAAC,gBAAgB,EAAE,CAAC;IACtB,OAAO,CAAC,KAAK,CAAC,uCAAuC,CAAC,CAAC;IACvD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,OAAO,GAAkB,MAAM,MAAM,CAAC,gBAAgB,CAAC,CAAC,IAAI,CAChE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAC3B,CAAC;AAEF,IAAI,OAAO,OAAO,KAAK,UAAU,EAAE,CAAC;IAClC,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;AAC5E,CAAC;AAED,MAAM,GAAG,GAAG,OAAO,EAAE,CAAC;AAEtB,GAAG,CAAC,GAAG,CAAC,IAAI,WAAW,EAAE,EAAE,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC;AACtD,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC;AAE9B,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,sCAAsC,IAAI,GAAG,CAAC,CAAC;AAC7D,CAAC,CAAC,CAAC"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Execute a request handler that renders a React component to a stream, then streams it back to the client.
3
+ * This function should be used as a request controller in an HTTP server.
4
+ * @param handler A request handler that renders a React component to a stream.
5
+ * @throws Error if the handler throws an error during rendering.
6
+ * @returns The stream generated by the handler
7
+ * @example ```ts
8
+ * import express from "express";
9
+ * import { serveStream } from "@canonical/react-ssr/server";
10
+ * import { JSXRenderer } from "@canonical/react-ssr/renderer";
11
+ * // htmlString is created by some build process that bundles the client code
12
+ * import htmlString from "../../dist/client/index.html?raw";
13
+ * import EntryServer from "./entry-server.js";
14
+ *
15
+ * // `EntryServer` is an instance of `@canonical/react-ssr/renderer/ReactServerEntrypointComponent`
16
+ * const Renderer = new JSXRenderer(EntryServer, {
17
+ * htmlString,
18
+ * });
19
+ *
20
+ * const ssrHandler = Renderer.render;
21
+ *
22
+ * const app = express();
23
+ *
24
+ * app.use("/(assets|public)", express.static("dist/client/assets"));
25
+ * app.use(serveStream(ssrHandler));
26
+ */
27
+ export function serveStream(handler) {
28
+ return (req, res) => {
29
+ try {
30
+ res.setHeader("Content-Type", "text/html");
31
+ res.setHeader("Transfer-Encoding", "chunked");
32
+ handler(req, res);
33
+ }
34
+ catch (error) {
35
+ console.error("Error during rendering:", error);
36
+ res.statusCode = 500;
37
+ res.end("Internal server error");
38
+ }
39
+ };
40
+ }
41
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../../../src/server/serve.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,WAAW,CAAC,OAAsB;IAChD,OAAO,CAAC,GAAoB,EAAE,GAAmB,EAAE,EAAE;QACnD,IAAI,CAAC;YACH,GAAG,CAAC,SAAS,CAAC,cAAc,EAAE,WAAW,CAAC,CAAC;YAC3C,GAAG,CAAC,SAAS,CAAC,mBAAmB,EAAE,SAAS,CAAC,CAAC;YAC9C,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACpB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,yBAAyB,EAAE,KAAK,CAAC,CAAC;YAChD,GAAG,CAAC,UAAU,GAAG,GAAG,CAAC;YACrB,GAAG,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;QACnC,CAAC;IACH,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,3 @@
1
+ export * as renderer from "./renderer/index.js";
2
+ export * as server from "./server/index.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC;AAChD,OAAO,KAAK,MAAM,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ /**
3
+ * Parses an HTML string to extract and convert script and link tags to React.createElement calls.
4
+ */
5
+ declare class Extractor {
6
+ private readonly document;
7
+ constructor(html: string);
8
+ private getElementsByTagName;
9
+ private convertKeyToReactKey;
10
+ private convertToReactElement;
11
+ getLinkTags(): React.ReactElement[];
12
+ getScriptTags(): React.ReactElement[];
13
+ }
14
+ export default Extractor;
15
+ //# sourceMappingURL=Extractor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Extractor.d.ts","sourceRoot":"","sources":["../../../src/renderer/Extractor.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B;;GAEG;AACH,cAAM,SAAS;IAEb,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAM;gBAEnB,IAAI,EAAE,MAAM;IAIxB,OAAO,CAAC,oBAAoB;IA0B5B,OAAO,CAAC,oBAAoB;IAa5B,OAAO,CAAC,qBAAqB;IAUtB,WAAW,IAAI,KAAK,CAAC,YAAY,EAAE;IAKnC,aAAa,IAAI,KAAK,CAAC,YAAY,EAAE;CAI7C;AAED,eAAe,SAAS,CAAC"}
@@ -0,0 +1,51 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type * as React from "react";
3
+ import { type PipeableStream, type RenderToPipeableStreamOptions } from "react-dom/server";
4
+ export interface RendererOptions {
5
+ defaultLocale?: string;
6
+ loadMessages?: (locale: string) => string;
7
+ /** The HTML string to extract the script and link tags from */
8
+ htmlString?: string;
9
+ /**
10
+ * Options to pass to `react-dom/server.renderToPipeableStream`
11
+ * We specifically exclude `onShellReady()`, `onError()`, and `onShellError()` as they are implemented by `JSXRenderer.render().`
12
+ * See https://react.dev/reference/react-dom/server/renderToPipeableStream#parameters
13
+ */
14
+ renderToPipeableStreamOptions?: Omit<RenderToPipeableStreamOptions, "onShellReady" | "onError" | "onShellError">;
15
+ }
16
+ /** The props that the server entrypoint component will receive */
17
+ export interface RendererServerEntrypointProps {
18
+ /** The language of the page. This is typically read from the request headers. */
19
+ lang?: string;
20
+ /** The script tags to include in the page */
21
+ scriptTags?: string;
22
+ /** The link tags to include in the page */
23
+ linkTags?: string;
24
+ }
25
+ /** The result of rendering a React component */
26
+ export type RenderResult = PipeableStream;
27
+ /** A function that renders a React component */
28
+ export type RenderHandler = (req: IncomingMessage, res: ServerResponse) => RenderResult;
29
+ export type ReactServerEntrypointComponent<TComponentProps extends RendererServerEntrypointProps> = React.ComponentType<TComponentProps>;
30
+ export default class Renderer<TComponent extends ReactServerEntrypointComponent<TComponentProps>, TComponentProps extends RendererServerEntrypointProps = RendererServerEntrypointProps> {
31
+ private readonly Component;
32
+ private readonly options;
33
+ private locale;
34
+ private extractor;
35
+ constructor(Component: TComponent, options?: RendererOptions);
36
+ /**
37
+ * Gets the props needed to render the component
38
+ * @return The props needed to render the component
39
+ * @private
40
+ */
41
+ private getComponentProps;
42
+ /**
43
+ * Renders this renderer's JSX component as a transmittable stream and sends it to the client
44
+ * TODO add a render function for ReadableStream, and rename this to be focused on PipeableStream
45
+ * @param req Client's request
46
+ * @param res Response object that will be sent to the client
47
+ * @return {RenderResult} The stream that was sent to the client
48
+ */
49
+ render: RenderHandler;
50
+ }
51
+ //# sourceMappingURL=JSXRenderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"JSXRenderer.d.ts","sourceRoot":"","sources":["../../../src/renderer/JSXRenderer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAC;AAEpC,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,6BAA6B,EAEnC,MAAM,kBAAkB,CAAC;AAG1B,MAAM,WAAW,eAAe;IAC9B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1C,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,6BAA6B,CAAC,EAAE,IAAI,CAClC,6BAA6B,EAC7B,cAAc,GAAG,SAAS,GAAG,cAAc,CAC5C,CAAC;CACH;AAED,kEAAkE;AAClE,MAAM,WAAW,6BAA6B;IAC5C,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAGD,gDAAgD;AAChD,MAAM,MAAM,YAAY,GAAG,cAAc,CAAC;AAC1C,gDAAgD;AAChD,MAAM,MAAM,aAAa,GAAG,CAC1B,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,KAChB,YAAY,CAAC;AAElB,MAAM,MAAM,8BAA8B,CACxC,eAAe,SAAS,6BAA6B,IACnD,KAAK,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC;AAGzC,MAAM,CAAC,OAAO,OAAO,QAAQ,CAC3B,UAAU,SAAS,8BAA8B,CAAC,eAAe,CAAC,EAClE,eAAe,SACb,6BAA6B,GAAG,6BAA6B;IAO7D,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAN1B,OAAO,CAAC,MAAM,CAAqB;IAEnC,OAAO,CAAC,SAAS,CAAwB;gBAGtB,SAAS,EAAE,UAAU,EACrB,OAAO,GAAE,eAAoB;IAkBhD;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;IAUzB;;;;;;OAMG;IACH,MAAM,EAAE,aAAa,CAoCnB;CACH"}
@@ -0,0 +1,4 @@
1
+ export { default as JSXRenderer } from "./JSXRenderer.js";
2
+ export type { RenderHandler, RenderResult, RendererServerEntrypointProps, ReactServerEntrypointComponent, RendererOptions, } from "./JSXRenderer.js";
3
+ export { default as Extractor } from "./Extractor.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/renderer/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC1D,YAAY,EACV,aAAa,EACb,YAAY,EACZ,6BAA6B,EAC7B,8BAA8B,EAC9B,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export * from "./serve.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC"}
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=serve-express.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve-express.d.ts","sourceRoot":"","sources":["../../../src/server/serve-express.ts"],"names":[],"mappings":""}
@@ -0,0 +1,30 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { RenderHandler } from "../renderer/index.js";
3
+ /**
4
+ * Execute a request handler that renders a React component to a stream, then streams it back to the client.
5
+ * This function should be used as a request controller in an HTTP server.
6
+ * @param handler A request handler that renders a React component to a stream.
7
+ * @throws Error if the handler throws an error during rendering.
8
+ * @returns The stream generated by the handler
9
+ * @example ```ts
10
+ * import express from "express";
11
+ * import { serveStream } from "@canonical/react-ssr/server";
12
+ * import { JSXRenderer } from "@canonical/react-ssr/renderer";
13
+ * // htmlString is created by some build process that bundles the client code
14
+ * import htmlString from "../../dist/client/index.html?raw";
15
+ * import EntryServer from "./entry-server.js";
16
+ *
17
+ * // `EntryServer` is an instance of `@canonical/react-ssr/renderer/ReactServerEntrypointComponent`
18
+ * const Renderer = new JSXRenderer(EntryServer, {
19
+ * htmlString,
20
+ * });
21
+ *
22
+ * const ssrHandler = Renderer.render;
23
+ *
24
+ * const app = express();
25
+ *
26
+ * app.use("/(assets|public)", express.static("dist/client/assets"));
27
+ * app.use(serveStream(ssrHandler));
28
+ */
29
+ export declare function serveStream(handler: RenderHandler): (req: IncomingMessage, res: ServerResponse) => void;
30
+ //# sourceMappingURL=serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../../src/server/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,SACnC,eAAe,OAAO,cAAc,UAWlD"}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@canonical/react-ssr",
3
+ "description": "TBD",
4
+ "version": "0.4.0-experimental.0",
5
+ "type": "module",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/types/index.d.ts",
8
+ "files": ["dist"],
9
+ "author": {
10
+ "email": "webteam@canonical.com",
11
+ "name": "Canonical Webteam"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/canonical/ds25"
16
+ },
17
+ "license": "LGPL-3.0",
18
+ "bugs": {
19
+ "url": "https://github.com/canonical/ds25/issues"
20
+ },
21
+ "homepage": "https://github.com/canonical/ds25#readme",
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.build.json",
24
+ "check": "bun run check:biome && bun run check:ts",
25
+ "check:fix": "bun run check:biome:fix && bun run check:ts",
26
+ "check:biome": "biome check src *.json",
27
+ "check:biome:fix": "biome check --write src *.json",
28
+ "check:ts": "tsc --noEmit"
29
+ },
30
+ "exports": {
31
+ ".": {
32
+ "import": "./dist/esm/index.js",
33
+ "types": "./dist/types/index.d.ts"
34
+ },
35
+ "./renderer": {
36
+ "import": "./dist/esm/renderer/index.js",
37
+ "types": "./dist/types/renderer/index.d.ts"
38
+ },
39
+ "./server": {
40
+ "import": "./dist/esm/server/index.js",
41
+ "types": "./dist/types/server/index.d.ts"
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@biomejs/biome": "^1.9.4",
46
+ "@canonical/biome-config": "^0.4.0-experimental.0",
47
+ "@canonical/typescript-config-base": "^0.4.0-experimental.0",
48
+ "@types/express": "^5.0.0",
49
+ "@types/react": "^19.0.1",
50
+ "@types/react-dom": "^19.0.2",
51
+ "typescript": "^5.7.2"
52
+ },
53
+ "dependencies": {
54
+ "@canonical/utils": "^0.4.0-experimental.0",
55
+ "domhandler": "^5.0.3",
56
+ "express": "^4.21.2",
57
+ "htmlparser2": "^9.1.0",
58
+ "react-dom": "^19.0.0"
59
+ }
60
+ }