@nerest/nerest 0.0.1
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 +30 -0
- package/bin/index.ts +13 -0
- package/bin/watch.ts +18 -0
- package/client/index.ts +40 -0
- package/dist/bin/index.d.ts +2 -0
- package/dist/bin/index.js +13 -0
- package/dist/bin/watch.d.ts +1 -0
- package/dist/bin/watch.js +18 -0
- package/dist/client/entry.d.ts +1 -0
- package/dist/client/entry.js +3 -0
- package/dist/server/app-parts/assets.d.ts +2 -0
- package/dist/server/app-parts/assets.js +15 -0
- package/dist/server/app-parts/examples.d.ts +1 -0
- package/dist/server/app-parts/examples.js +31 -0
- package/dist/server/app-parts/preview.d.ts +1 -0
- package/dist/server/app-parts/preview.js +27 -0
- package/dist/server/apps.d.ts +10 -0
- package/dist/server/apps.js +48 -0
- package/dist/server/assets.d.ts +1 -0
- package/dist/server/assets.js +28 -0
- package/dist/server/entry.d.ts +2 -0
- package/dist/server/entry.js +17 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.js +102 -0
- package/dist/server/preview.d.ts +1 -0
- package/dist/server/preview.js +31 -0
- package/dist/server/render.d.ts +6 -0
- package/dist/server/render.js +26 -0
- package/package.json +75 -0
- package/server/app-parts/assets.ts +17 -0
- package/server/app-parts/examples.ts +30 -0
- package/server/apps.ts +61 -0
- package/server/index.ts +125 -0
- package/server/preview.ts +30 -0
- package/server/render.ts +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @nerest/nerest
|
|
2
|
+
|
|
3
|
+
React micro frontend framework
|
|
4
|
+
|
|
5
|
+
> TODO: purpose, outline, documentation
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm i --save @nerest/nerest react react-dom
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Conventions
|
|
14
|
+
|
|
15
|
+
The `apps` directory must contain all of the apps provided by the micro frontend. E.g. `/apps/foo/index.tsx` is the entrypoint component of the `foo` app. It becomes available as the `/api/foo` route of the micro frontend server.
|
|
16
|
+
|
|
17
|
+
The single app directory may contain an `examples` subdirectory with example JSON files which can be used as props for the app entrypoint component. E.g. `/apps/foo/examples/example-1.json` becomes available as the `/api/foo/examples/example-1` route of the micro frontend server.
|
|
18
|
+
|
|
19
|
+
See [nerest-harness](https://github.com/nerestjs/harness) for the minimal example of a nerest micro frontend.
|
|
20
|
+
|
|
21
|
+
## Development
|
|
22
|
+
|
|
23
|
+
Run the build script to build the framework.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
npm install
|
|
27
|
+
npm run build
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Use [nerest-harness](https://github.com/nerestjs/harness) with `npm link` to test changes locally.
|
package/bin/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// All executions of `nerest <command>` get routed through here
|
|
3
|
+
import { watch } from './watch';
|
|
4
|
+
|
|
5
|
+
async function cliEntry(args: string[]) {
|
|
6
|
+
if (args[0] === 'watch') {
|
|
7
|
+
await watch();
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// [<path to node>, <path to nerest binary>, ...args]
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
cliEntry(args);
|
package/bin/watch.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createServer } from '../server';
|
|
2
|
+
|
|
3
|
+
// Start dev server in watch mode, that restarts on file change
|
|
4
|
+
// and rebuilds the client static files
|
|
5
|
+
export async function watch() {
|
|
6
|
+
// TODO: will be replaced with nerest logger
|
|
7
|
+
console.log('Starting Nerest watch...');
|
|
8
|
+
|
|
9
|
+
const { app } = await createServer();
|
|
10
|
+
|
|
11
|
+
// TODO: remove hardcoded port
|
|
12
|
+
await app.listen({
|
|
13
|
+
host: '0.0.0.0',
|
|
14
|
+
port: 3000,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
console.log('Nerest is listening on 0.0.0.0:3000');
|
|
18
|
+
}
|
package/client/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Shared client entrypoint that bootstraps all of the apps supplied
|
|
2
|
+
// by current microfrontend
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import ReactDOM from 'react-dom/client';
|
|
5
|
+
|
|
6
|
+
// Since this is a shared entrypoint, it dynamically imports all of the
|
|
7
|
+
// available apps. They will be built as separate chunks and only loaded
|
|
8
|
+
// if needed.
|
|
9
|
+
const modules = import.meta.glob('/apps/*/index.tsx', { import: 'default' });
|
|
10
|
+
|
|
11
|
+
async function runHydration() {
|
|
12
|
+
for (const container of document.querySelectorAll('div[data-app-name]')) {
|
|
13
|
+
const appName = container.getAttribute('data-app-name');
|
|
14
|
+
const appModuleLoader = modules[`/apps/${appName}/index.tsx`];
|
|
15
|
+
|
|
16
|
+
if (!appModuleLoader || container.hasAttribute('data-app-hydrated')) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Mark container as hydrated, in case there are multiple instances
|
|
21
|
+
// of the same microfrontend script on the page
|
|
22
|
+
container.setAttribute('data-app-hydrated', 'true');
|
|
23
|
+
|
|
24
|
+
const appId = container.getAttribute('data-app-id');
|
|
25
|
+
const propsContainer = document.querySelector(
|
|
26
|
+
`script[data-app-id="${appId}"]`
|
|
27
|
+
);
|
|
28
|
+
// TODO: more robust error handling and error logging
|
|
29
|
+
const props = JSON.parse(propsContainer?.textContent ?? '{}');
|
|
30
|
+
|
|
31
|
+
const reactElement = React.createElement(await appModuleLoader(), props);
|
|
32
|
+
ReactDOM.hydrateRoot(container, reactElement);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (document.readyState !== 'complete') {
|
|
37
|
+
document.addEventListener('DOMContentLoaded', runHydration);
|
|
38
|
+
} else {
|
|
39
|
+
runHydration();
|
|
40
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
// All executions of `nerest <command>` get routed through here
|
|
5
|
+
const watch_1 = require("./watch");
|
|
6
|
+
async function cliEntry(args) {
|
|
7
|
+
if (args[0] === 'watch') {
|
|
8
|
+
await (0, watch_1.watch)();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
// [<path to node>, <path to nerest binary>, ...args]
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
cliEntry(args);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function watch(): Promise<void>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.watch = void 0;
|
|
4
|
+
const server_1 = require("../server");
|
|
5
|
+
// Start dev server in watch mode, that restarts on file change
|
|
6
|
+
// and rebuilds the client static files
|
|
7
|
+
async function watch() {
|
|
8
|
+
// TODO: will be replaced with nerest logger
|
|
9
|
+
console.log('Starting Nerest watch...');
|
|
10
|
+
const { app } = await (0, server_1.createServer)();
|
|
11
|
+
// TODO: remove hardcoded port
|
|
12
|
+
await app.listen({
|
|
13
|
+
host: '0.0.0.0',
|
|
14
|
+
port: 3000,
|
|
15
|
+
});
|
|
16
|
+
console.log('Nerest is listening on 0.0.0.0:3000');
|
|
17
|
+
}
|
|
18
|
+
exports.watch = watch;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadAppAssets = void 0;
|
|
4
|
+
// TODO: this is not as simple in the real world
|
|
5
|
+
const publicPath = process.env.PUBLIC_PATH ?? 'http://0.0.0.0:3000/';
|
|
6
|
+
// Extracts the list of assets for a given app from the manifest file
|
|
7
|
+
function loadAppAssets(manifest, appName) {
|
|
8
|
+
const entries = Object.entries(manifest);
|
|
9
|
+
// TODO: handling errors and potentially missing entries
|
|
10
|
+
const clientEntryJs = entries.find(([_, entry]) => entry.isEntry)?.[1].file;
|
|
11
|
+
const appCss = entries.find(([name, _]) => name.includes(`/${appName}/index.tsx`))?.[1]
|
|
12
|
+
.css ?? [];
|
|
13
|
+
return [clientEntryJs, ...appCss].map((x) => publicPath + x);
|
|
14
|
+
}
|
|
15
|
+
exports.loadAppAssets = loadAppAssets;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function loadAppExamples(appRoot: string): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadAppExamples = void 0;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
10
|
+
// Loads and parses the example json files for providing
|
|
11
|
+
// `/examples/` routes of the dev server
|
|
12
|
+
async function loadAppExamples(appRoot) {
|
|
13
|
+
const examplesRoot = path_1.default.join(appRoot, 'examples');
|
|
14
|
+
// Examples are optional and may not exist
|
|
15
|
+
if (!(0, fs_1.existsSync)(examplesRoot)) {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
const exampleFiles = (await promises_1.default.readdir(examplesRoot, { withFileTypes: true }))
|
|
19
|
+
.filter((d) => d.isFile() && d.name.endsWith('.json'))
|
|
20
|
+
.map((d) => d.name);
|
|
21
|
+
const examples = {};
|
|
22
|
+
// TODO: error handling and reporting
|
|
23
|
+
for (const filename of exampleFiles) {
|
|
24
|
+
const file = path_1.default.join(examplesRoot, filename);
|
|
25
|
+
const content = await promises_1.default.readFile(file, { encoding: 'utf8' });
|
|
26
|
+
const json = JSON.parse(content);
|
|
27
|
+
examples[path_1.default.basename(filename, '.json')] = json;
|
|
28
|
+
}
|
|
29
|
+
return examples;
|
|
30
|
+
}
|
|
31
|
+
exports.loadAppExamples = loadAppExamples;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renderPreviewPage(html: string, assets: string[]): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderPreviewPage = void 0;
|
|
4
|
+
function renderPreviewPage(html, assets) {
|
|
5
|
+
const { scripts, styles } = mapAssets(assets);
|
|
6
|
+
return `
|
|
7
|
+
<html>
|
|
8
|
+
<head>
|
|
9
|
+
${styles.join('\n')}
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
${html}
|
|
13
|
+
${scripts.join('\n')}
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
exports.renderPreviewPage = renderPreviewPage;
|
|
19
|
+
function mapAssets(assets) {
|
|
20
|
+
const scripts = assets
|
|
21
|
+
.filter((src) => src.endsWith('.js'))
|
|
22
|
+
.map((src) => `<script type="module" src="${src}"></script>`);
|
|
23
|
+
const styles = assets
|
|
24
|
+
.filter((src) => src.endsWith('.css'))
|
|
25
|
+
.map((src) => `<link rel="stylesheet" href="${src}">`);
|
|
26
|
+
return { scripts, styles };
|
|
27
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadApps = void 0;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
9
|
+
const assets_1 = require("./app-parts/assets");
|
|
10
|
+
const examples_1 = require("./app-parts/examples");
|
|
11
|
+
// Build the record of the available apps by convention
|
|
12
|
+
// apps -> /apps/{name}/index.tsx
|
|
13
|
+
// examples -> /apps/{name}/examples/{example}.json
|
|
14
|
+
async function loadApps(root) {
|
|
15
|
+
const appsRoot = path_1.default.join(root, 'apps');
|
|
16
|
+
const manifest = await loadManifest(root);
|
|
17
|
+
const appsDirs = (await promises_1.default.readdir(appsRoot, { withFileTypes: true }))
|
|
18
|
+
.filter((d) => d.isDirectory())
|
|
19
|
+
.map((d) => d.name);
|
|
20
|
+
const apps = [];
|
|
21
|
+
for (const appDir of appsDirs) {
|
|
22
|
+
apps.push(await loadApp(appsRoot, appDir, manifest));
|
|
23
|
+
}
|
|
24
|
+
return Object.fromEntries(apps);
|
|
25
|
+
}
|
|
26
|
+
exports.loadApps = loadApps;
|
|
27
|
+
async function loadApp(appsRoot, name, manifest) {
|
|
28
|
+
// TODO: report problems with loading entries, assets and/or examples
|
|
29
|
+
const appRoot = path_1.default.join(appsRoot, name);
|
|
30
|
+
return [
|
|
31
|
+
name,
|
|
32
|
+
{
|
|
33
|
+
name,
|
|
34
|
+
root: appRoot,
|
|
35
|
+
entry: path_1.default.join(appRoot, 'index.tsx'),
|
|
36
|
+
assets: (0, assets_1.loadAppAssets)(manifest, name),
|
|
37
|
+
examples: await (0, examples_1.loadAppExamples)(appRoot),
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
// Manifest is used to provide assets list for every app
|
|
42
|
+
// for use with SSR
|
|
43
|
+
async function loadManifest(root) {
|
|
44
|
+
// TODO: error handling
|
|
45
|
+
const manifestPath = path_1.default.join(root, 'dist', 'manifest.json');
|
|
46
|
+
const manifestData = await promises_1.default.readFile(manifestPath, { encoding: 'utf8' });
|
|
47
|
+
return JSON.parse(manifestData);
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getAppAssets(appName: string, root: string): Promise<string[]>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getAppAssets = void 0;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
9
|
+
// TODO: way more complicated in the real world
|
|
10
|
+
const publicPath = process.env.PUBLIC_PATH ?? 'http://127.0.0.1:3000/';
|
|
11
|
+
async function getAppAssets(appName, root) {
|
|
12
|
+
const manifest = await loadManifest(root);
|
|
13
|
+
const assets = extractAssetsFromManifest(manifest, appName);
|
|
14
|
+
return assets.map((x) => publicPath + x);
|
|
15
|
+
}
|
|
16
|
+
exports.getAppAssets = getAppAssets;
|
|
17
|
+
async function loadManifest(root) {
|
|
18
|
+
const manifestPath = path_1.default.join(root, 'dist', 'manifest.json');
|
|
19
|
+
const manifestData = await promises_1.default.readFile(manifestPath, { encoding: 'utf8' });
|
|
20
|
+
return JSON.parse(manifestData);
|
|
21
|
+
}
|
|
22
|
+
function extractAssetsFromManifest(manifest, appName) {
|
|
23
|
+
const entries = Object.entries(manifest);
|
|
24
|
+
const clientEntryJs = entries.find(([_, entry]) => entry.isEntry)?.[1].file;
|
|
25
|
+
const appCss = entries.find(([name, _]) => name.includes(`/${appName}/index.tsx`))?.[1]
|
|
26
|
+
.css ?? [];
|
|
27
|
+
return [clientEntryJs, ...appCss];
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renderSsrComponent = void 0;
|
|
7
|
+
const react_1 = __importDefault(require("react"));
|
|
8
|
+
const server_1 = require("react-dom/server");
|
|
9
|
+
const nanoid_1 = require("nanoid");
|
|
10
|
+
function renderSsrComponent(appName, AppComponent, props = {}) {
|
|
11
|
+
const html = (0, server_1.renderToString)(react_1.default.createElement(AppComponent, { ...props }));
|
|
12
|
+
const appId = (0, nanoid_1.nanoid)();
|
|
13
|
+
const container = `<div data-app-name="${appName}" data-app-id="${appId}">${html}</div>`;
|
|
14
|
+
const script = `<script type="application/json" data-app-id="${appId}">${JSON.stringify(props)}</script>`;
|
|
15
|
+
return container + script;
|
|
16
|
+
}
|
|
17
|
+
exports.renderSsrComponent = renderSsrComponent;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import type { ServerResponse } from 'http';
|
|
3
|
+
export declare function createServer(): Promise<{
|
|
4
|
+
app: import("fastify").FastifyInstance<import("http").Server<typeof import("http").IncomingMessage, typeof ServerResponse>, import("http").IncomingMessage, ServerResponse<import("http").IncomingMessage>, import("fastify").FastifyBaseLogger, import("fastify").FastifyTypeProviderDefault> & PromiseLike<import("fastify").FastifyInstance<import("http").Server<typeof import("http").IncomingMessage, typeof ServerResponse>, import("http").IncomingMessage, ServerResponse<import("http").IncomingMessage>, import("fastify").FastifyBaseLogger, import("fastify").FastifyTypeProviderDefault>>;
|
|
5
|
+
}>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createServer = void 0;
|
|
7
|
+
// This is the nerest server entrypoint
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const vite_1 = __importDefault(require("vite"));
|
|
10
|
+
const fastify_1 = __importDefault(require("fastify"));
|
|
11
|
+
const static_1 = __importDefault(require("@fastify/static"));
|
|
12
|
+
const apps_1 = require("./apps");
|
|
13
|
+
const render_1 = require("./render");
|
|
14
|
+
const preview_1 = require("./preview");
|
|
15
|
+
// TODO: this turned out to be a dev server, production server
|
|
16
|
+
// will most likely be implemented separately
|
|
17
|
+
async function createServer() {
|
|
18
|
+
const root = process.cwd();
|
|
19
|
+
// TODO: move build config into a separate file
|
|
20
|
+
// TODO: look at @vitejs/plugin-react (everything seems fine without it though)
|
|
21
|
+
// TODO: look at @vitejs/plugin-legacy (needed for browsers without module support)
|
|
22
|
+
const config = {
|
|
23
|
+
root,
|
|
24
|
+
appType: 'custom',
|
|
25
|
+
server: { middlewareMode: true },
|
|
26
|
+
build: {
|
|
27
|
+
// Manifest is needed to report used assets in SSR handles
|
|
28
|
+
manifest: true,
|
|
29
|
+
modulePreload: false,
|
|
30
|
+
// TODO: watch is only necessary for the client build
|
|
31
|
+
watch: {},
|
|
32
|
+
rollupOptions: {
|
|
33
|
+
input: '/node_modules/@nerest/nerest/client/index.ts',
|
|
34
|
+
output: {
|
|
35
|
+
entryFileNames: `assets/[name].js`,
|
|
36
|
+
chunkFileNames: `assets/[name].js`,
|
|
37
|
+
assetFileNames: `assets/[name].[ext]`,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
// Build the clientside assets and watch for changes
|
|
43
|
+
// TODO: this should probably be moved from here
|
|
44
|
+
await startClientBuildWatcher(config);
|
|
45
|
+
// Load app entries following the `apps/{name}/index.tsx` convention
|
|
46
|
+
const apps = await (0, apps_1.loadApps)(root);
|
|
47
|
+
// Start vite server that will be rendering SSR components
|
|
48
|
+
const viteSsr = await vite_1.default.createServer(config);
|
|
49
|
+
const app = (0, fastify_1.default)();
|
|
50
|
+
for (const appEntry of Object.values(apps)) {
|
|
51
|
+
const { name, entry, examples } = appEntry;
|
|
52
|
+
// POST /api/{name} -> render app with request.body as props
|
|
53
|
+
app.post(`/api/${name}`, async (request) => {
|
|
54
|
+
// ssrLoadModule drives the "hot-reload" logic, and allows
|
|
55
|
+
// picking up changes to the source without restarting the server
|
|
56
|
+
const ssrComponent = await viteSsr.ssrLoadModule(entry, {
|
|
57
|
+
fixStacktrace: true,
|
|
58
|
+
});
|
|
59
|
+
return (0, render_1.renderApp)(appEntry, ssrComponent.default, request.body);
|
|
60
|
+
});
|
|
61
|
+
for (const [exampleName, example] of Object.entries(examples)) {
|
|
62
|
+
// GET /api/{name}/examples/{example} -> render a preview page
|
|
63
|
+
// with a predefined example body
|
|
64
|
+
app.get(`/api/${name}/examples/${exampleName}`, async (_, reply) => {
|
|
65
|
+
const ssrComponent = await viteSsr.ssrLoadModule(entry, {
|
|
66
|
+
fixStacktrace: true,
|
|
67
|
+
});
|
|
68
|
+
const { html, assets } = (0, render_1.renderApp)(appEntry, ssrComponent.default, example);
|
|
69
|
+
reply.type('text/html');
|
|
70
|
+
return (0, preview_1.renderPreviewPage)(html, assets);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// TODO: only do this locally, load from CDN in production
|
|
75
|
+
app.register(static_1.default, {
|
|
76
|
+
root: path_1.default.join(root, 'dist'),
|
|
77
|
+
// TODO: maybe use @fastify/cors instead
|
|
78
|
+
setHeaders(res) {
|
|
79
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
80
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET');
|
|
81
|
+
res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With, content-type, Authorization');
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
return { app };
|
|
85
|
+
}
|
|
86
|
+
exports.createServer = createServer;
|
|
87
|
+
// TODO: this should probably be moved from here
|
|
88
|
+
async function startClientBuildWatcher(config) {
|
|
89
|
+
const watcher = (await vite_1.default.build(config));
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
// We need to have a built manifest.json to provide assets
|
|
92
|
+
// links in SSR. We will wait for rollup to report when it
|
|
93
|
+
// has finished the build
|
|
94
|
+
const listener = (ev) => {
|
|
95
|
+
if (ev.code === 'END') {
|
|
96
|
+
watcher.off('event', listener);
|
|
97
|
+
resolve();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
watcher.on('event', listener);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renderPreviewPage(html: string, assets: string[]): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderPreviewPage = void 0;
|
|
4
|
+
// Renders the preview page available by convention at /api/{name}/examples/{example}
|
|
5
|
+
function renderPreviewPage(html, assets) {
|
|
6
|
+
const { scripts, styles } = mapAssets(assets);
|
|
7
|
+
return `
|
|
8
|
+
<html>
|
|
9
|
+
<head>
|
|
10
|
+
${styles.join('\n')}
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
${html}
|
|
14
|
+
${scripts.join('\n')}
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
19
|
+
exports.renderPreviewPage = renderPreviewPage;
|
|
20
|
+
function mapAssets(assets) {
|
|
21
|
+
// TODO: script type="module" is not supported by older browsers
|
|
22
|
+
// but vite doesn't provide `nomodule` fallback by default
|
|
23
|
+
// see @vitejs/plugin-legacy
|
|
24
|
+
const scripts = assets
|
|
25
|
+
.filter((src) => src.endsWith('.js'))
|
|
26
|
+
.map((src) => `<script type="module" src="${src}"></script>`);
|
|
27
|
+
const styles = assets
|
|
28
|
+
.filter((src) => src.endsWith('.css'))
|
|
29
|
+
.map((src) => `<link rel="stylesheet" href="${src}">`);
|
|
30
|
+
return { scripts, styles };
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renderApp = void 0;
|
|
7
|
+
const react_1 = __importDefault(require("react"));
|
|
8
|
+
const server_1 = require("react-dom/server");
|
|
9
|
+
const nanoid_1 = require("nanoid");
|
|
10
|
+
function renderApp(appEntry, appComponent, props = {}) {
|
|
11
|
+
const { name, assets } = appEntry;
|
|
12
|
+
const html = renderSsrComponent(name, appComponent, props);
|
|
13
|
+
return { html, assets };
|
|
14
|
+
}
|
|
15
|
+
exports.renderApp = renderApp;
|
|
16
|
+
function renderSsrComponent(appName, appComponent, props) {
|
|
17
|
+
const html = (0, server_1.renderToString)(react_1.default.createElement(appComponent, props));
|
|
18
|
+
// There may be multiple instances of the same app on the page,
|
|
19
|
+
// so we will use a randomized id to avoid collisions
|
|
20
|
+
const appId = (0, nanoid_1.nanoid)();
|
|
21
|
+
// data-app-name and data-app-id are used by client entrypoint to hydrate
|
|
22
|
+
// apps using correct serialized props
|
|
23
|
+
const container = `<div data-app-name="${appName}" data-app-id="${appId}">${html}</div>`;
|
|
24
|
+
const script = `<script type="application/json" data-app-id="${appId}">${JSON.stringify(props)}</script>`;
|
|
25
|
+
return container + script;
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nerest/nerest",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "React micro frontend framework",
|
|
5
|
+
"homepage": "https://github.com/nerestjs/nerest#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/nerestjs/nerest.git"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"nerest": "dist/bin/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"/dist",
|
|
15
|
+
"/bin",
|
|
16
|
+
"/client",
|
|
17
|
+
"/server",
|
|
18
|
+
"/README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json -d",
|
|
22
|
+
"lint": "eslint --ext .ts server bin",
|
|
23
|
+
"prepare": "simple-git-hooks"
|
|
24
|
+
},
|
|
25
|
+
"simple-git-hooks": {
|
|
26
|
+
"pre-commit": "npx lint-staged"
|
|
27
|
+
},
|
|
28
|
+
"lint-staged": {
|
|
29
|
+
"*.ts": [
|
|
30
|
+
"eslint --fix",
|
|
31
|
+
"prettier --write"
|
|
32
|
+
],
|
|
33
|
+
"*.{json,md}": [
|
|
34
|
+
"prettier --write"
|
|
35
|
+
],
|
|
36
|
+
"package.json": [
|
|
37
|
+
"sort-package-json"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"prettier": "@tinkoff/prettier-config",
|
|
41
|
+
"eslintConfig": {
|
|
42
|
+
"extends": [
|
|
43
|
+
"@tinkoff/eslint-config/lib",
|
|
44
|
+
"@tinkoff/eslint-config/jest",
|
|
45
|
+
"@tinkoff/eslint-config-react"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@fastify/static": "^6.5.0",
|
|
50
|
+
"fastify": "^4.9.2",
|
|
51
|
+
"nanoid": "^3.3.4",
|
|
52
|
+
"vite": "^3.2.3"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@tinkoff/eslint-config": "^1.41.0",
|
|
56
|
+
"@tinkoff/eslint-config-react": "^1.41.0",
|
|
57
|
+
"@tinkoff/prettier-config": "^1.32.1",
|
|
58
|
+
"@types/react": "^18.0.25",
|
|
59
|
+
"@types/react-dom": "^18.0.8",
|
|
60
|
+
"jest": "^29.3.1",
|
|
61
|
+
"lint-staged": "^13.0.3",
|
|
62
|
+
"react": "^18.2.0",
|
|
63
|
+
"react-dom": "^18.2.0",
|
|
64
|
+
"simple-git-hooks": "^2.8.1",
|
|
65
|
+
"sort-package-json": "^2.1.0",
|
|
66
|
+
"typescript": "^4.8.4"
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"react": "^18.0.0",
|
|
70
|
+
"react-dom": "^18.0.0"
|
|
71
|
+
},
|
|
72
|
+
"engines": {
|
|
73
|
+
"node": ">=16.0.0"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Manifest } from 'vite';
|
|
2
|
+
|
|
3
|
+
// TODO: this is not as simple in the real world
|
|
4
|
+
const publicPath = process.env.PUBLIC_PATH ?? 'http://0.0.0.0:3000/';
|
|
5
|
+
|
|
6
|
+
// Extracts the list of assets for a given app from the manifest file
|
|
7
|
+
export function loadAppAssets(manifest: Manifest, appName: string) {
|
|
8
|
+
const entries = Object.entries(manifest);
|
|
9
|
+
|
|
10
|
+
// TODO: handling errors and potentially missing entries
|
|
11
|
+
const clientEntryJs = entries.find(([_, entry]) => entry.isEntry)?.[1].file;
|
|
12
|
+
const appCss =
|
|
13
|
+
entries.find(([name, _]) => name.includes(`/${appName}/index.tsx`))?.[1]
|
|
14
|
+
.css ?? [];
|
|
15
|
+
|
|
16
|
+
return [clientEntryJs, ...appCss].map((x) => publicPath + x);
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
|
|
5
|
+
// Loads and parses the example json files for providing
|
|
6
|
+
// `/examples/` routes of the dev server
|
|
7
|
+
export async function loadAppExamples(appRoot: string) {
|
|
8
|
+
const examplesRoot = path.join(appRoot, 'examples');
|
|
9
|
+
|
|
10
|
+
// Examples are optional and may not exist
|
|
11
|
+
if (!existsSync(examplesRoot)) {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const exampleFiles = (await fs.readdir(examplesRoot, { withFileTypes: true }))
|
|
16
|
+
.filter((d) => d.isFile() && d.name.endsWith('.json'))
|
|
17
|
+
.map((d) => d.name);
|
|
18
|
+
|
|
19
|
+
const examples: Record<string, unknown> = {};
|
|
20
|
+
|
|
21
|
+
// TODO: error handling and reporting
|
|
22
|
+
for (const filename of exampleFiles) {
|
|
23
|
+
const file = path.join(examplesRoot, filename);
|
|
24
|
+
const content = await fs.readFile(file, { encoding: 'utf8' });
|
|
25
|
+
const json = JSON.parse(content);
|
|
26
|
+
examples[path.basename(filename, '.json')] = json;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return examples;
|
|
30
|
+
}
|
package/server/apps.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import type { Manifest } from 'vite';
|
|
4
|
+
|
|
5
|
+
import { loadAppAssets } from './app-parts/assets';
|
|
6
|
+
import { loadAppExamples } from './app-parts/examples';
|
|
7
|
+
|
|
8
|
+
export type AppEntry = {
|
|
9
|
+
name: string;
|
|
10
|
+
root: string;
|
|
11
|
+
entry: string;
|
|
12
|
+
assets: string[];
|
|
13
|
+
examples: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Build the record of the available apps by convention
|
|
17
|
+
// apps -> /apps/{name}/index.tsx
|
|
18
|
+
// examples -> /apps/{name}/examples/{example}.json
|
|
19
|
+
export async function loadApps(root: string) {
|
|
20
|
+
const appsRoot = path.join(root, 'apps');
|
|
21
|
+
const manifest = await loadManifest(root);
|
|
22
|
+
|
|
23
|
+
const appsDirs = (await fs.readdir(appsRoot, { withFileTypes: true }))
|
|
24
|
+
.filter((d) => d.isDirectory())
|
|
25
|
+
.map((d) => d.name);
|
|
26
|
+
|
|
27
|
+
const apps: Array<[name: string, entry: AppEntry]> = [];
|
|
28
|
+
for (const appDir of appsDirs) {
|
|
29
|
+
apps.push(await loadApp(appsRoot, appDir, manifest));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return Object.fromEntries(apps);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loadApp(
|
|
36
|
+
appsRoot: string,
|
|
37
|
+
name: string,
|
|
38
|
+
manifest: Manifest
|
|
39
|
+
): Promise<[name: string, entry: AppEntry]> {
|
|
40
|
+
// TODO: report problems with loading entries, assets and/or examples
|
|
41
|
+
const appRoot = path.join(appsRoot, name);
|
|
42
|
+
return [
|
|
43
|
+
name,
|
|
44
|
+
{
|
|
45
|
+
name,
|
|
46
|
+
root: appRoot,
|
|
47
|
+
entry: path.join(appRoot, 'index.tsx'),
|
|
48
|
+
assets: loadAppAssets(manifest, name),
|
|
49
|
+
examples: await loadAppExamples(appRoot),
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Manifest is used to provide assets list for every app
|
|
55
|
+
// for use with SSR
|
|
56
|
+
async function loadManifest(root: string) {
|
|
57
|
+
// TODO: error handling
|
|
58
|
+
const manifestPath = path.join(root, 'dist', 'manifest.json');
|
|
59
|
+
const manifestData = await fs.readFile(manifestPath, { encoding: 'utf8' });
|
|
60
|
+
return JSON.parse(manifestData);
|
|
61
|
+
}
|
package/server/index.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// This is the nerest server entrypoint
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import type { ServerResponse } from 'http';
|
|
4
|
+
|
|
5
|
+
import vite from 'vite';
|
|
6
|
+
import type { InlineConfig } from 'vite';
|
|
7
|
+
import type { RollupWatcher, RollupWatcherEvent } from 'rollup';
|
|
8
|
+
|
|
9
|
+
import fastify from 'fastify';
|
|
10
|
+
import fastifyStatic from '@fastify/static';
|
|
11
|
+
|
|
12
|
+
import { loadApps } from './apps';
|
|
13
|
+
import { renderApp } from './render';
|
|
14
|
+
import { renderPreviewPage } from './preview';
|
|
15
|
+
|
|
16
|
+
// TODO: this turned out to be a dev server, production server
|
|
17
|
+
// will most likely be implemented separately
|
|
18
|
+
export async function createServer() {
|
|
19
|
+
const root = process.cwd();
|
|
20
|
+
|
|
21
|
+
// TODO: move build config into a separate file
|
|
22
|
+
// TODO: look at @vitejs/plugin-react (everything seems fine without it though)
|
|
23
|
+
// TODO: look at @vitejs/plugin-legacy (needed for browsers without module support)
|
|
24
|
+
const config: InlineConfig = {
|
|
25
|
+
root,
|
|
26
|
+
appType: 'custom',
|
|
27
|
+
server: { middlewareMode: true },
|
|
28
|
+
build: {
|
|
29
|
+
// Manifest is needed to report used assets in SSR handles
|
|
30
|
+
manifest: true,
|
|
31
|
+
modulePreload: false,
|
|
32
|
+
// TODO: watch is only necessary for the client build
|
|
33
|
+
watch: {},
|
|
34
|
+
rollupOptions: {
|
|
35
|
+
input: '/node_modules/@nerest/nerest/client/index.ts',
|
|
36
|
+
output: {
|
|
37
|
+
entryFileNames: `assets/[name].js`,
|
|
38
|
+
chunkFileNames: `assets/[name].js`,
|
|
39
|
+
assetFileNames: `assets/[name].[ext]`,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Build the clientside assets and watch for changes
|
|
46
|
+
// TODO: this should probably be moved from here
|
|
47
|
+
await startClientBuildWatcher(config);
|
|
48
|
+
|
|
49
|
+
// Load app entries following the `apps/{name}/index.tsx` convention
|
|
50
|
+
const apps = await loadApps(root);
|
|
51
|
+
|
|
52
|
+
// Start vite server that will be rendering SSR components
|
|
53
|
+
const viteSsr = await vite.createServer(config);
|
|
54
|
+
const app = fastify();
|
|
55
|
+
|
|
56
|
+
for (const appEntry of Object.values(apps)) {
|
|
57
|
+
const { name, entry, examples } = appEntry;
|
|
58
|
+
|
|
59
|
+
// POST /api/{name} -> render app with request.body as props
|
|
60
|
+
app.post(`/api/${name}`, async (request) => {
|
|
61
|
+
// ssrLoadModule drives the "hot-reload" logic, and allows
|
|
62
|
+
// picking up changes to the source without restarting the server
|
|
63
|
+
const ssrComponent = await viteSsr.ssrLoadModule(entry, {
|
|
64
|
+
fixStacktrace: true,
|
|
65
|
+
});
|
|
66
|
+
return renderApp(
|
|
67
|
+
appEntry,
|
|
68
|
+
ssrComponent.default,
|
|
69
|
+
request.body as Record<string, unknown>
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
for (const [exampleName, example] of Object.entries(examples)) {
|
|
74
|
+
// GET /api/{name}/examples/{example} -> render a preview page
|
|
75
|
+
// with a predefined example body
|
|
76
|
+
app.get(`/api/${name}/examples/${exampleName}`, async (_, reply) => {
|
|
77
|
+
const ssrComponent = await viteSsr.ssrLoadModule(entry, {
|
|
78
|
+
fixStacktrace: true,
|
|
79
|
+
});
|
|
80
|
+
const { html, assets } = renderApp(
|
|
81
|
+
appEntry,
|
|
82
|
+
ssrComponent.default,
|
|
83
|
+
example as Record<string, unknown>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
reply.type('text/html');
|
|
87
|
+
|
|
88
|
+
return renderPreviewPage(html, assets);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// TODO: only do this locally, load from CDN in production
|
|
94
|
+
app.register(fastifyStatic, {
|
|
95
|
+
root: path.join(root, 'dist'),
|
|
96
|
+
// TODO: maybe use @fastify/cors instead
|
|
97
|
+
setHeaders(res: ServerResponse) {
|
|
98
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
99
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET');
|
|
100
|
+
res.setHeader(
|
|
101
|
+
'Access-Control-Allow-Headers',
|
|
102
|
+
'X-Requested-With, content-type, Authorization'
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return { app };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// TODO: this should probably be moved from here
|
|
111
|
+
async function startClientBuildWatcher(config: InlineConfig) {
|
|
112
|
+
const watcher = (await vite.build(config)) as RollupWatcher;
|
|
113
|
+
return new Promise<void>((resolve) => {
|
|
114
|
+
// We need to have a built manifest.json to provide assets
|
|
115
|
+
// links in SSR. We will wait for rollup to report when it
|
|
116
|
+
// has finished the build
|
|
117
|
+
const listener = (ev: RollupWatcherEvent) => {
|
|
118
|
+
if (ev.code === 'END') {
|
|
119
|
+
watcher.off('event', listener);
|
|
120
|
+
resolve();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
watcher.on('event', listener);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Renders the preview page available by convention at /api/{name}/examples/{example}
|
|
2
|
+
export function renderPreviewPage(html: string, assets: string[]) {
|
|
3
|
+
const { scripts, styles } = mapAssets(assets);
|
|
4
|
+
return `
|
|
5
|
+
<html>
|
|
6
|
+
<head>
|
|
7
|
+
${styles.join('\n')}
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
${html}
|
|
11
|
+
${scripts.join('\n')}
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
14
|
+
`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mapAssets(assets: string[]) {
|
|
18
|
+
// TODO: script type="module" is not supported by older browsers
|
|
19
|
+
// but vite doesn't provide `nomodule` fallback by default
|
|
20
|
+
// see @vitejs/plugin-legacy
|
|
21
|
+
const scripts = assets
|
|
22
|
+
.filter((src) => src.endsWith('.js'))
|
|
23
|
+
.map((src) => `<script type="module" src="${src}"></script>`);
|
|
24
|
+
|
|
25
|
+
const styles = assets
|
|
26
|
+
.filter((src) => src.endsWith('.css'))
|
|
27
|
+
.map((src) => `<link rel="stylesheet" href="${src}">`);
|
|
28
|
+
|
|
29
|
+
return { scripts, styles };
|
|
30
|
+
}
|
package/server/render.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderToString } from 'react-dom/server';
|
|
3
|
+
import { nanoid } from 'nanoid';
|
|
4
|
+
|
|
5
|
+
import type { AppEntry } from './apps';
|
|
6
|
+
|
|
7
|
+
export function renderApp(
|
|
8
|
+
appEntry: AppEntry,
|
|
9
|
+
appComponent: React.ComponentType,
|
|
10
|
+
props: Record<string, unknown> = {}
|
|
11
|
+
) {
|
|
12
|
+
const { name, assets } = appEntry;
|
|
13
|
+
const html = renderSsrComponent(name, appComponent, props);
|
|
14
|
+
return { html, assets };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderSsrComponent(
|
|
18
|
+
appName: string,
|
|
19
|
+
appComponent: React.ComponentType,
|
|
20
|
+
props: Record<string, unknown>
|
|
21
|
+
) {
|
|
22
|
+
const html = renderToString(React.createElement(appComponent, props));
|
|
23
|
+
|
|
24
|
+
// There may be multiple instances of the same app on the page,
|
|
25
|
+
// so we will use a randomized id to avoid collisions
|
|
26
|
+
const appId = nanoid();
|
|
27
|
+
|
|
28
|
+
// data-app-name and data-app-id are used by client entrypoint to hydrate
|
|
29
|
+
// apps using correct serialized props
|
|
30
|
+
const container = `<div data-app-name="${appName}" data-app-id="${appId}">${html}</div>`;
|
|
31
|
+
const script = `<script type="application/json" data-app-id="${appId}">${JSON.stringify(
|
|
32
|
+
props
|
|
33
|
+
)}</script>`;
|
|
34
|
+
|
|
35
|
+
return container + script;
|
|
36
|
+
}
|