@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 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
+ }
@@ -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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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,3 @@
1
+ const apps = import.meta.glob('/apps/*/index.tsx', { import: 'default' });
2
+ console.log(apps);
3
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { Manifest } from 'vite';
2
+ export declare function loadAppAssets(manifest: Manifest, appName: string): string[];
@@ -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,10 @@
1
+ export declare type AppEntry = {
2
+ name: string;
3
+ root: string;
4
+ entry: string;
5
+ assets: string[];
6
+ examples: Record<string, unknown>;
7
+ };
8
+ export declare function loadApps(root: string): Promise<{
9
+ [k: string]: AppEntry;
10
+ }>;
@@ -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,2 @@
1
+ import React from 'react';
2
+ export declare function renderSsrComponent(appName: string, AppComponent: React.ComponentType, props?: Record<string, unknown>): string;
@@ -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,6 @@
1
+ import React from 'react';
2
+ import type { AppEntry } from './apps';
3
+ export declare function renderApp(appEntry: AppEntry, appComponent: React.ComponentType, props?: Record<string, unknown>): {
4
+ html: string;
5
+ assets: string[];
6
+ };
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }