From ef59ca4ebe581ebfd2a6e8ea8e50fa5853fd26f2 Mon Sep 17 00:00:00 2001 From: Vladimir Shestakov <boolive@yandex.ru> Date: Wed, 22 Nov 2023 21:16:42 +0300 Subject: [PATCH] deleted server --- server/config.ts | 26 ------ server/index.ts | 20 ----- server/routers/common/index.ts | 22 ------ server/routers/index.ts | 11 --- server/routers/initial/index.ts | 18 ----- server/routers/proxy/index.ts | 38 --------- server/routers/render/index.ts | 135 -------------------------------- server/types.ts | 29 ------- server/utils/initial-store.ts | 44 ----------- server/utils/loadEnv.ts | 14 ---- server/utils/react-template.ts | 78 ------------------ server/utils/stream-helmet.ts | 29 ------- 12 files changed, 464 deletions(-) delete mode 100644 server/config.ts delete mode 100644 server/index.ts delete mode 100644 server/routers/common/index.ts delete mode 100644 server/routers/index.ts delete mode 100644 server/routers/initial/index.ts delete mode 100644 server/routers/proxy/index.ts delete mode 100644 server/routers/render/index.ts delete mode 100644 server/types.ts delete mode 100644 server/utils/initial-store.ts delete mode 100644 server/utils/loadEnv.ts delete mode 100644 server/utils/react-template.ts delete mode 100644 server/utils/stream-helmet.ts diff --git a/server/config.ts b/server/config.ts deleted file mode 100644 index dbd9d6b..0000000 --- a/server/config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {IServerConfig} from "./types"; - -export default (env: ImportMetaEnv): IServerConfig => { - const config: IServerConfig = { - // HTTP сервер для рендера. Параметры также используются для dev сервера Vite - server: { - host: env.HOST || 'localhost', - port: env.PORT || 8050, - }, - proxy: { - enabled: true,//env.PROD, //В dev режиме работает прокси Vite(в режиме middleware), но у него ошибка на POST запросы, поэтому включен свой прокси - routes: {} - }, - render: { - enabled: true, - } - }; - if (env.API_PATH) { - config.proxy.routes[env.API_PATH] = { - target: env.API_URL, - secure: false, - changeOrigin: true, - }; - } - return config; -}; diff --git a/server/index.ts b/server/index.ts deleted file mode 100644 index fd3f42d..0000000 --- a/server/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * HTTP server for render - */ -import express from "express"; -import InitialStore from "./utils/initial-store"; -import routers from './routers/index'; -import serverConfig from "./config"; -import loadEnv from "./utils/loadEnv"; - -(async () => { - const env = loadEnv(); - const config = serverConfig(env); - const initialStore = new InitialStore(); - const app = express(); - for (const route of routers) { - await route({app, initialStore, config, env}); - } - app.listen(config.server.port); - console.info(`Server run on http://${config.server.host}:${config.server.port}`); -})().catch(console.warn); diff --git a/server/routers/common/index.ts b/server/routers/common/index.ts deleted file mode 100644 index 22155a4..0000000 --- a/server/routers/common/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import path from "path"; -import express from "express"; -import cookieParser from "cookie-parser"; -import {fileURLToPath} from "url"; -import {IRouteContext} from "../../types"; - -/** - * Общие обработки запроса в express и роут на production файлы. - * @param app - * @param config - * @param env - */ -export default async ({app, env}: IRouteContext) => { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - // Отдача файлов кроме index.html - if (env.PROD) { - app.use(express.static(path.resolve(__dirname, '../../../dist/client'), {index: false})); - } - app.use(express.json()); // for parsing application/json - app.use(express.urlencoded({extended: true})); // for parsing application/x-www-form-urlencoded - app.use(cookieParser()); -}; diff --git a/server/routers/index.ts b/server/routers/index.ts deleted file mode 100644 index a59c859..0000000 --- a/server/routers/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import initial from "./initial/index.js"; -import common from "./common/index.js"; -import proxy from "./proxy/index.js"; -import render from "./render/index.js"; - -export default [ - proxy, - common, - initial, - render, -]; diff --git a/server/routers/initial/index.ts b/server/routers/initial/index.ts deleted file mode 100644 index b6396d4..0000000 --- a/server/routers/initial/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {IRouteContext} from "../../types"; - -/** - * Роут для отдачи состояния (данных) после рендера React приложения - * Состояние не отправляется вместе с HTML, его необходимо запросить по ключу - * Состояние можно получить один раз, после чего оно удаляется из хранилища. - * @param app - * @param initialStore - */ -export default async ({app, initialStore}: IRouteContext) => { - // Выборка состояния с которым выполнялся рендер - // Выборка доступна один раз - app.get('/initial/:key', async (req, res) => { - const key = req.params.key; - const secret = req.cookies.stateSecret; - res.json(initialStore.get({key, secret}) || {}); - }); -}; diff --git a/server/routers/proxy/index.ts b/server/routers/proxy/index.ts deleted file mode 100644 index 3985ff7..0000000 --- a/server/routers/proxy/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import httpProxy from "http-proxy"; -import {IRouteContext} from "../../types"; - -/** - * Проксирование запросов в соответствии с настройками приложения - * Обычно проксируются запросы к АПИ для обхода CORS - * @param app - * @param config - */ -export default async ({app, config}: IRouteContext) => { - - if (config.proxy.enabled) { - // // Прокси на внешний сервер по конфигу (обычно для апи) - const proxy = httpProxy.createProxyServer({/*timeout: 5000, */proxyTimeout: 5000}); - proxy.on('error', function (err, req, res) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - res.writeHead(500, {'Content-Type': 'text/plain'}); - res.end(err.toString()); - }); - - app.use((req, res, next) => { - for (const path of Object.keys(config.proxy.routes)) { - if ((path[0] === '^' && new RegExp(path).test(req.url)) || req.url.startsWith(path)) { - try { - return proxy.web(req, res, config.proxy.routes[path]); - } catch (e) { - console.error(e); - res.send(500); - } - } - } - next(); - }); - - console.log(`Proxy in SSR enabled`); - } -}; diff --git a/server/routers/render/index.ts b/server/routers/render/index.ts deleted file mode 100644 index a7315b4..0000000 --- a/server/routers/render/index.ts +++ /dev/null @@ -1,135 +0,0 @@ -import React from "react"; -import {renderToPipeableStream} from "react-dom/server"; -import reactTemplate from "../../utils/react-template"; -import streamHelmet from "../../utils/stream-helmet"; -import {createServer as createViteServer} from "vite"; -import fs from "fs"; -import path from "path"; -import {IRouteContext} from "../../types"; -import {Request, Response} from "express"; -import {fileURLToPath} from "url"; -import {HelmetServerState} from "react-helmet-async"; - -/** - * SSR - рендер React приложения в HTML - * @param app Express приложение - * @param initialStore Хранилище для запоминания состояния сервисов - * @param config Настройки сервера - * @param env Переменные окружения - */ -export default async ({app, initialStore, config, env}: IRouteContext) => { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - // Fix for render; - React.useLayoutEffect = React.useEffect; - - // Сборщик Vite для рендера в режиме разработки - const vite = env.DEV ? await createViteServer({ - server: {middlewareMode: true}, - appType: 'custom', - }) : undefined; - if (vite) app.use(vite.middlewares); - - // React приложение для ренедра. В режиме разработки импортируются исходники через Vite - const root = vite - ? (await vite.ssrLoadModule('../src/root.tsx')).default - : (await import('../../../dist/server/root.js')).default; - - // HTML шаблон - const rootTemplate = fs.readFileSync( - path.resolve(__dirname, env.DEV - ? '../../../src/index.html' - : '../../../dist/client/index.html'), - 'utf-8', - ); - - // Рендер - app.get('/*', async (req: Request, res: Response) => { - // Запрос на файл, которого нет (чтобы не рендерить приложение из-за этого) - // Если файл есть, то он бы отправился обработчиком файлов - if (req.originalUrl.match(/\.[a-z0-9]+$/u)) { - res.writeHead(404, {'Content-Type': 'text/html; charset=utf-8'}); - res.end('Not Found'); - return; - } - - // В режиме разработки в шаблон вставляются скрипты Vite для горячего обновления - const template = vite - ? await vite.transformIndexHtml(req.originalUrl, rootTemplate) - : rootTemplate; - - // Если рендер отключен, то отдаём index.html (шаблон) - if (!config.render.enabled) { - res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'}); - res.end(template); - return; - } - - // @todo Если есть кэш, то отдать его. Предусмотреть, что состояние тоже было запомнено (возможно, отдельно и временно помещается в initialStore) - - // Секрет для идентификации дампа от всех сервисов - const secret = initialStore.makeSecretKey(); - - // Корневой React компонент, сервис менеджер приложения и контекст с мета-данными html - const {Root, servicesManager, head} = await root({ - ...env, - req: { - url: req.originalUrl, - headers: req.headers, - cookies: req.cookies - } - }); - - // HTML шаблон конвертирует в ReactNode со вставкой в него компонента приложения. - // Из шаблона вычленяются скрипты, чтобы отдать их в потоке, но после html разметки. - const {jsx, scripts, modules} = reactTemplate(Root, template); - - // Признак ошибки - let didError = false; - - const sendHeaders = (res: Response) => { - res.cookie('stateSecret', secret.secret, {httpOnly: true/*, maxAge: 100/*, secure: true*/}); - res.writeHead(didError ? 500 : 200, { - 'Content-Type': 'text/html; charset=utf-8', - 'Cache-Control': 'public,max-age=300,s-maxage=900' - }); - }; - - const {pipe, abort} = renderToPipeableStream(jsx, { - bootstrapScriptContent: `window.initialKey="${secret.key}"`, - bootstrapModules: modules, - bootstrapScripts: scripts, - // Частичный рендер требует отдачи состояния вместе с каждым патчем (не реализовано) - // onShellReady() { - // if (config.render.partial) { - // sendHeaders(res); - // pipe(res); - // } - // }, - // onShellError(error) { - // res.writeHead(500, {'Content-Type': 'text/html; charset=utf-8'}); - // res.send('<h1>Something went wrong</h1>'); - // if (vite) vite.ssrFixStacktrace(error as Error); - // console.error(error); - // }, - onAllReady() { - // if (!config.render.partial) { - sendHeaders(res); - if ('helmet' in head) { - streamHelmet(pipe, head.helmet as HelmetServerState).pipe(res); - } - // @todo Если нужно кэшировать, то понадобится читать поток рендера - // @todo Дамп также кэшируется, а не запомианется надолго в initialStore - /// } - // Дамп всех сервисов запоминается в initialStore - initialStore.remember(secret, servicesManager.collectDump()); - }, - onError(error) { - didError = true; - if (vite) vite.ssrFixStacktrace(error as Error); - console.error(error); - }, - }); - // Timeout - setTimeout(() => abort(), 10000); - }); -}; diff --git a/server/types.ts b/server/types.ts deleted file mode 100644 index a3ad697..0000000 --- a/server/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {Application} from "express"; -import InitialStore from "./utils/initial-store"; -import {ServerOptions} from "http-proxy"; - -export interface IServerConfig { - server: { - host: string, - port: number, - }, - proxy: { - // Включить прокси на сервере рендара - enabled: boolean - // Пути перенаправления запросов. - routes: { - [path: string]: ServerOptions - } - }, - render: { - // SSR или отдать SPA? Можно использовать для включения рендера только для поисковых ботов - enabled: boolean, - } -} - -export interface IRouteContext { - app: Application, - initialStore: InitialStore, - config: IServerConfig, - env: ImportMetaEnv -} diff --git a/server/utils/initial-store.ts b/server/utils/initial-store.ts deleted file mode 100644 index cc3a100..0000000 --- a/server/utils/initial-store.ts +++ /dev/null @@ -1,44 +0,0 @@ -import uniqid from "uniqid"; - -export type TSecretKey = { - key: string; - secret: string; -} - -/** - * Хранилище состояний, с доступом по двойному ключу (паре строк) - * Доступ к данным одноразовый. - * Используется для запоминания состояния, с которым рендерилось приложение. - */ -export default class InitialStore { - private items: Map<string, {[secret: string]: unknown}>; - - constructor() { - this.items = new Map(); - } - - makeSecretKey(){ - const key = uniqid(); - const secret = uniqid(key); - return {key, secret}; - } - - remember({key, secret}: TSecretKey, state: unknown, time = 15000){ - this.items.set(key, { [secret]: state }); - setTimeout(() => { - //delete this.items.delete(key); - }, time); - } - - get({key, secret}: TSecretKey){ - let result = undefined; - if (this.items.has(key)){ - const record = this.items.get(key); - if (record){ - result = record[secret]; - this.items.delete(key); - } - } - return result; - } -} diff --git a/server/utils/loadEnv.ts b/server/utils/loadEnv.ts deleted file mode 100644 index 9c7056b..0000000 --- a/server/utils/loadEnv.ts +++ /dev/null @@ -1,14 +0,0 @@ -import typedVariables from 'dotenv-parse-variables'; -import {loadEnv as loadEnvVite} from "vite"; - -export default function loadEnv(){ - return { - SSR: true, - MODE: process.env.NODE_ENV, - PROD: !process.env.NODE_ENV || process.env.NODE_ENV === 'production', - DEV: Boolean(process.env.NODE_ENV && process.env.NODE_ENV !== 'production'), - ...typedVariables( - loadEnvVite(process.env.NODE_ENV || 'production', process.cwd(), '') - ) - } as ImportMetaEnv; -} diff --git a/server/utils/react-template.ts b/server/utils/react-template.ts deleted file mode 100644 index c7b4a02..0000000 --- a/server/utils/react-template.ts +++ /dev/null @@ -1,78 +0,0 @@ -import parse from "html-dom-parser"; -import React, {FunctionComponent, ReactNode} from "react"; - -export type TAttrMapNames = { [attr: string]: string }; -export type TAttributes = { [attr: string]: string | number | boolean | null }; -export type TDomItems = ReturnType<typeof parse> - -/** - * Словарь переименования атрибутов - */ -const attrMap: TAttrMapNames = { - 'xml:lang': 'xmlLang', - 'http-equiv': 'httpEquiv', - 'crossorigin': 'crossOrigin', - 'class': 'className' -}; - -/** - * переименование атрибутов в формат React JSX - * @param attr - * @param names - */ -function attrRenames(attr: TAttributes, names: TAttrMapNames = attrMap) { - const keys = Object.keys(attr); - const result: TAttributes = {}; - for (const key of keys) { - if (key in names) { - result[names[key]] = attr[key]; - } else { - result[key] = attr[key]; - } - } - return result; -} - -/** - * Конвертирует строковый HTML шаблон в react элемент со вставкой в него компонента React - * @param Component React компонент (функция) - * @param template HTML шаблон - * @param place Идентификатор html тега, внутри которого вставить компонент - * @param props Свойства компоненту - */ -export default function reactTemplate(Component: FunctionComponent, template: string, place = 'app', props = {}) { - const domTree = parse(template.trim().replace(/>\s+</ug, '><')); - const scripts: string[] = []; - const modules: string[] = []; - - const buildReactElements = (nodes: TDomItems): ReactNode[] => { - const result = []; - for (const item of nodes) { - const children = 'children' in item ? buildReactElements(item.children as TDomItems) : []; - if (item.type === 'tag') { - if (item.attribs.id === place) children.push(React.createElement(Component, props)); - result.push(React.createElement(item.name, attrRenames(item.attribs), ...children)); - } else if (item.type === 'text') { - result.push(item.data); - } else if (item.type === 'script') { - if (children.length) { - result.push(React.createElement(item.name, { - ...attrRenames(item.attribs), - dangerouslySetInnerHTML: {__html: children.join('')} - })); - } else { - if (item.attribs.type === 'module') { - modules.push(item.attribs.src); - } else { - scripts.push(item.attribs.src); - } - } - } - } - return result; - }; - - const result = buildReactElements(domTree); - - return {jsx: result.at(0), scripts, modules}; -} diff --git a/server/utils/stream-helmet.ts b/server/utils/stream-helmet.ts deleted file mode 100644 index 3805401..0000000 --- a/server/utils/stream-helmet.ts +++ /dev/null @@ -1,29 +0,0 @@ -import replacestream from 'replacestream'; -import {HelmetServerState} from "react-helmet-async"; - -type TPipe = <Writable extends NodeJS.WritableStream>(destination: Writable) => Writable; - -/** - * Трансформация потока - вставка мета-тегов перед закрытием </head> - * @param pipe - * @param helmet - * @return {*} - */ -export default function streamHelmet(pipe: TPipe, helmet: HelmetServerState) { - return pipe( - replacestream(/<html[^>]*>/ui, `<html ${helmet.htmlAttributes.toString()}>`, {limit: 1}) - ).pipe( - replacestream(/<title>[^<]*<\/title>/ui, helmet.title.toString(), {limit: 1}) - ).pipe( - replacestream('</head>', - helmet.meta.toString() + - helmet.link.toString() + - helmet.script.toString() + - helmet.noscript.toString() + - helmet.style.toString() + - '</head>' - , {limit: 1}) - ).pipe( - replacestream(/<body[^>]*>/ui, `<body ${helmet.bodyAttributes.toString()}>`, {limit: 1}) - ); -}