├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .nycrc ├── .prettierignore ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── package-lock.json ├── package.json ├── prettier.config.js ├── project.d.ts ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── application │ ├── entities │ │ ├── domain │ │ │ ├── news │ │ │ │ ├── api │ │ │ │ │ ├── getNewsItemById.ts │ │ │ │ │ └── getNewsList.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── model │ │ │ │ │ ├── cache │ │ │ │ │ │ └── getNewsItemDataFromCache.ts │ │ │ │ │ ├── common.ts │ │ │ │ │ ├── fetch │ │ │ │ │ │ ├── useInfinityNewsList.ts │ │ │ │ │ │ ├── useNewsItemById.ts │ │ │ │ │ │ └── usePaginatedNewsList.ts │ │ │ │ │ ├── invalidate │ │ │ │ │ │ └── useInvalidateAllNewsQueries.ts │ │ │ │ │ └── refetch │ │ │ │ │ │ └── useRefetchAllNewsQueries.ts │ │ │ │ ├── types.ts │ │ │ │ └── ui │ │ │ │ │ └── item │ │ │ │ │ ├── index.css.ts │ │ │ │ │ └── index.tsx │ │ │ └── user │ │ │ │ ├── api │ │ │ │ ├── deleteUser.ts │ │ │ │ ├── getUserById.ts │ │ │ │ ├── getUserList.ts │ │ │ │ ├── patchUser.ts │ │ │ │ ├── postUser.ts │ │ │ │ └── shared.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── model │ │ │ │ ├── common.ts │ │ │ │ ├── fetch │ │ │ │ │ ├── useUserById.ts │ │ │ │ │ └── useUserList.ts │ │ │ │ ├── invalidate │ │ │ │ │ └── useInvalidateAllUserQueries.ts │ │ │ │ ├── mutate │ │ │ │ │ ├── useAddUser.tsx │ │ │ │ │ ├── useDeleteUser.tsx │ │ │ │ │ └── useEditUser.tsx │ │ │ │ └── refetch │ │ │ │ │ └── useRefetchAllUserQueries.ts │ │ │ │ ├── types.ts │ │ │ │ └── ui │ │ │ │ └── status │ │ │ │ └── index.tsx │ │ └── ui │ │ │ ├── navigation │ │ │ ├── hooks │ │ │ │ ├── useActivePage.ts │ │ │ │ ├── useNavigate.ts │ │ │ │ └── useURLQuery.ts │ │ │ ├── index.tsx │ │ │ └── ui │ │ │ │ └── link │ │ │ │ ├── context.tsx │ │ │ │ ├── index.css.ts │ │ │ │ └── index.tsx │ │ │ └── userForm │ │ │ └── index.tsx │ ├── entry │ │ ├── client │ │ │ └── index.tsx │ │ ├── common │ │ │ ├── metadata │ │ │ │ └── getTitle.ts │ │ │ └── react │ │ │ │ ├── index.css.ts │ │ │ │ └── index.tsx │ │ └── server │ │ │ ├── index.tsx │ │ │ ├── metadata │ │ │ └── getMetadata.ts │ │ │ ├── middlewares │ │ │ └── UAParser.ts │ │ │ ├── routes │ │ │ ├── data.ts │ │ │ └── fakeCrud.ts │ │ │ └── utils │ │ │ └── onErrorFallbackHTML.ts │ ├── features │ │ ├── addUser │ │ │ ├── index.tsx │ │ │ └── ui │ │ │ │ └── addUser.tsx │ │ ├── development │ │ │ ├── index.tsx │ │ │ ├── readme.md │ │ │ └── ui │ │ │ │ ├── devMenu │ │ │ │ ├── index.css.ts │ │ │ │ └── index.tsx │ │ │ │ ├── projectInfo │ │ │ │ └── index.tsx │ │ │ │ └── sourceSpoiler │ │ │ │ └── index.tsx │ │ ├── editUser │ │ │ ├── index.tsx │ │ │ └── ui │ │ │ │ └── userEditor.tsx │ │ ├── fakeAPIConfigurator │ │ │ ├── index.tsx │ │ │ └── ui │ │ │ │ └── fakeAPIConfigurator.tsx │ │ ├── newsItem │ │ │ ├── index.css.ts │ │ │ └── index.tsx │ │ ├── newsList │ │ │ ├── index.css.ts │ │ │ ├── index.tsx │ │ │ ├── infinity │ │ │ │ └── index.tsx │ │ │ ├── item │ │ │ │ ├── index.tsx │ │ │ │ └── styles.css.ts │ │ │ └── paginated │ │ │ │ └── index.tsx │ │ ├── search │ │ │ ├── index.tsx │ │ │ └── ui │ │ │ │ └── search │ │ │ │ ├── index.css.ts │ │ │ │ └── index.tsx │ │ ├── staticComponent │ │ │ ├── index.tsx │ │ │ └── ui │ │ │ │ └── staticDataComponent │ │ │ │ └── index.tsx │ │ └── userList │ │ │ ├── index.tsx │ │ │ └── ui │ │ │ ├── row │ │ │ └── index.tsx │ │ │ └── userList │ │ │ └── index.tsx │ ├── pages │ │ ├── _internals │ │ │ └── index.ts │ │ ├── error │ │ │ ├── index.tsx │ │ │ ├── metadata.ts │ │ │ └── routing.ts │ │ ├── news │ │ │ ├── index.tsx │ │ │ ├── metadata.ts │ │ │ ├── routing.ts │ │ │ └── ui.tsx │ │ ├── newsItem │ │ │ ├── index.tsx │ │ │ ├── metadata.ts │ │ │ ├── routing.ts │ │ │ └── ui.tsx │ │ ├── root │ │ │ ├── index.tsx │ │ │ ├── metadata.ts │ │ │ ├── routing.ts │ │ │ └── ui.tsx │ │ ├── shared.ts │ │ └── users │ │ │ ├── index.tsx │ │ │ ├── metadata.ts │ │ │ ├── routing.ts │ │ │ └── ui.tsx │ └── shared │ │ ├── config │ │ ├── defaults │ │ │ ├── application.ts │ │ │ └── server.ts │ │ ├── hook.ts │ │ ├── index.ts │ │ └── types.ts │ │ ├── constants │ │ └── cookies.ts │ │ ├── hooks │ │ ├── useAppSuspenseInfiniteQuery.ts │ │ ├── useAppSuspenseQuery.ts │ │ └── useSomethingWentWrongToast.tsx │ │ ├── kit │ │ ├── fadeIn │ │ │ ├── index.css.ts │ │ │ └── index.tsx │ │ ├── flexbox │ │ │ └── index.tsx │ │ ├── glass │ │ │ ├── components │ │ │ │ └── glass │ │ │ │ │ ├── index.css.ts │ │ │ │ │ └── index.tsx │ │ │ ├── constants.ts │ │ │ ├── context.tsx │ │ │ ├── hook.ts │ │ │ └── index.tsx │ │ ├── lazy │ │ │ └── index.tsx │ │ ├── popover │ │ │ ├── container.tsx │ │ │ ├── index.tsx │ │ │ ├── popover.tsx │ │ │ ├── shared.ts │ │ │ └── utils.ts │ │ ├── popup │ │ │ ├── basePopup │ │ │ │ ├── index.css.ts │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ ├── infrastructure │ │ │ │ ├── context.tsx │ │ │ │ ├── controller.ts │ │ │ │ └── hook.ts │ │ │ ├── popup │ │ │ │ ├── index.css.ts │ │ │ │ └── index.tsx │ │ │ └── types.ts │ │ ├── preloader │ │ │ └── index.tsx │ │ ├── spoiler │ │ │ └── index.tsx │ │ ├── toast │ │ │ ├── index.tsx │ │ │ ├── infrastructure │ │ │ │ ├── context.tsx │ │ │ │ ├── controller.ts │ │ │ │ └── hook.ts │ │ │ ├── toast │ │ │ │ ├── index.css.ts │ │ │ │ └── index.tsx │ │ │ ├── toasts │ │ │ │ ├── index.css.ts │ │ │ │ └── index.tsx │ │ │ └── types.ts │ │ └── zIndex │ │ │ ├── index.css.ts │ │ │ └── index.tsx │ │ ├── lib │ │ ├── api │ │ │ ├── createApi.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── useApi.ts │ │ │ └── useApiContext.ts │ │ ├── query │ │ │ ├── index.tsx │ │ │ ├── reactQueryBoundary.tsx │ │ │ └── types.ts │ │ ├── request │ │ │ └── index.tsx │ │ ├── routing │ │ │ ├── index.ts │ │ │ └── parsePageQueryParam.ts │ │ └── showPageName │ │ │ └── index.ts │ │ └── styles │ │ ├── functions.ts │ │ ├── global.css.ts │ │ └── shared.ts ├── framework │ ├── applications │ │ ├── client │ │ │ ├── index.tsx │ │ │ ├── store │ │ │ │ ├── index.ts │ │ │ │ ├── middleware │ │ │ │ │ └── title.ts │ │ │ │ └── startup.ts │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ ├── addStoreSubscribers.ts │ │ │ │ ├── afterAppRendered.ts │ │ │ │ └── createClientSessionObject.ts │ │ ├── server │ │ │ ├── createApplicationRouteHandler.tsx │ │ │ ├── index.ts │ │ │ ├── logs │ │ │ │ ├── logApplicationBootstrapError.ts │ │ │ │ ├── logServerUncaughtException.ts │ │ │ │ └── logServerUnhandledRejection.ts │ │ │ ├── middlewares │ │ │ │ ├── clientIP.ts │ │ │ │ ├── routerErrorHandler.ts │ │ │ │ └── searchBots.ts │ │ │ ├── routes │ │ │ │ └── utility.ts │ │ │ ├── store │ │ │ │ ├── index.ts │ │ │ │ └── startup.ts │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ ├── assets.ts │ │ │ │ ├── createOnFinishHadler.ts │ │ │ │ ├── createServerSessionObject.ts │ │ │ │ ├── generateShell.ts │ │ │ │ ├── reactStreamRenderEnhancer.ts │ │ │ │ └── reduxLogger.ts │ │ ├── shared │ │ │ └── logger.ts │ │ └── shell.tsx │ ├── config │ │ ├── generator │ │ │ ├── client.ts │ │ │ ├── server.ts │ │ │ └── shared.ts │ │ ├── react │ │ │ └── index.tsx │ │ ├── types.ts │ │ └── utils │ │ │ ├── __tests__ │ │ │ └── parseEnvParams.spec.ts │ │ │ └── parseEnvParams.ts │ ├── constants │ │ ├── application.ts │ │ └── cookies.ts │ ├── infrastructure │ │ ├── css │ │ │ ├── __tests__ │ │ │ │ ├── generateCss.spec.ts │ │ │ │ ├── serverStore.ts │ │ │ │ └── utils.spec.ts │ │ │ ├── generator │ │ │ │ ├── index.ts │ │ │ │ ├── prefixer.ts │ │ │ │ └── utils.ts │ │ │ ├── hook.ts │ │ │ ├── loadAllStylesOnClient.ts │ │ │ ├── provider │ │ │ │ ├── clientStore.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── serverStore.ts │ │ │ │ └── types.ts │ │ │ ├── stringHash │ │ │ │ ├── __tests__ │ │ │ │ │ └── stringHash.spec.ts │ │ │ │ └── index.ts │ │ │ ├── types.ts │ │ │ └── webpack │ │ │ │ ├── common.ts │ │ │ │ ├── loader │ │ │ │ └── index.ts │ │ │ │ ├── plugin │ │ │ │ └── index.ts │ │ │ │ └── store.ts │ │ ├── lazy │ │ │ ├── index.tsx │ │ │ └── retry.ts │ │ ├── logger │ │ │ ├── clientLog.ts │ │ │ ├── index.ts │ │ │ ├── init │ │ │ │ ├── client.ts │ │ │ │ ├── index.ts │ │ │ │ └── server.ts │ │ │ ├── react │ │ │ │ ├── context.tsx │ │ │ │ └── hook.ts │ │ │ ├── serverLog.ts │ │ │ ├── stub.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── platform │ │ │ ├── cookie │ │ │ │ ├── client.ts │ │ │ │ ├── server.ts │ │ │ │ ├── stub │ │ │ │ │ └── index.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ ├── shared │ │ │ │ └── context.tsx │ │ │ ├── stubedPlatformAPICreator.ts │ │ │ └── window │ │ │ │ ├── client.ts │ │ │ │ ├── server.ts │ │ │ │ ├── stub │ │ │ │ └── index.ts │ │ │ │ └── types.ts │ │ ├── query │ │ │ ├── defaultOptions.ts │ │ │ ├── getDehydratedQueryStateFromDom.ts │ │ │ ├── types.ts │ │ │ ├── useAnyAppSuspenseInfiniteQuery.ts │ │ │ ├── useAnyAppSuspenseQuery.ts │ │ │ ├── useHydrateQuery.ts │ │ │ └── useResetCacheOnUnmount.ts │ │ ├── raise │ │ │ ├── react │ │ │ │ ├── component.tsx │ │ │ │ └── context.tsx │ │ │ └── store.ts │ │ ├── request │ │ │ ├── __tests__ │ │ │ │ ├── patchUrlProtocol.spec.ts │ │ │ │ └── processAnyAPIError.spec.ts │ │ │ ├── error.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ ├── abortController.ts │ │ │ │ ├── generateRequestId.ts │ │ │ │ ├── getRequestContentType.ts │ │ │ │ ├── is.ts │ │ │ │ ├── patchUrlProtocol.ts │ │ │ │ ├── prepareFetchParams.ts │ │ │ │ ├── processAnyAPIError.ts │ │ │ │ ├── response.ts │ │ │ │ └── tests.ts │ │ ├── router │ │ │ ├── __tests__ │ │ │ │ ├── compileURL.spec.ts │ │ │ │ ├── mock.spec.ts │ │ │ │ ├── parseURL.spec.ts │ │ │ │ └── utils.spec.ts │ │ │ ├── bindRouteConfigToPathCreator.ts │ │ │ ├── compileURL.ts │ │ │ ├── createRouteConfigCreator.ts │ │ │ ├── hooks │ │ │ │ ├── useAnyActivePage.ts │ │ │ │ ├── useCommonNavigate.ts │ │ │ │ └── useURLQueryParams.ts │ │ │ ├── parseURL.ts │ │ │ ├── redux │ │ │ │ ├── actions │ │ │ │ │ ├── appContext │ │ │ │ │ │ ├── openPageAction.ts │ │ │ │ │ │ └── setQueryStringParams.ts │ │ │ │ │ └── router.ts │ │ │ │ ├── hooks │ │ │ │ │ └── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── historyActons.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── navigate.ts │ │ │ │ ├── selectors │ │ │ │ │ └── index.ts │ │ │ │ ├── signals │ │ │ │ │ └── page.ts │ │ │ │ └── store │ │ │ │ │ ├── configureStore.ts │ │ │ │ │ ├── context.tsx │ │ │ │ │ └── reducer.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── session │ │ │ ├── context.tsx │ │ │ ├── hook.ts │ │ │ └── types.ts │ │ ├── signal │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── middleware.ts │ │ │ ├── tests │ │ │ │ ├── index.spec.ts │ │ │ │ └── integration.spec.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tests │ │ │ ├── dom │ │ │ │ ├── dt │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── index.spec.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── env │ │ │ │ │ └── index.js │ │ │ │ └── location │ │ │ │ │ └── index.ts │ │ │ ├── hooks │ │ │ │ └── beforeAndAfterEach.ts │ │ │ └── stub │ │ │ │ └── index.ts │ │ └── webpack │ │ │ ├── constants.ts │ │ │ └── getFullPathForStaticResource.ts │ ├── public │ │ ├── client.tsx │ │ ├── constants.ts │ │ ├── readme.md │ │ ├── server.tsx │ │ ├── styles.ts │ │ ├── tests.ts │ │ ├── types.ts │ │ └── universal.tsx │ └── types │ │ └── metadata.ts └── lib │ ├── browser │ ├── __tests__ │ │ └── index.spec.ts │ └── index.ts │ ├── console │ ├── __tests__ │ │ └── index.spec.ts │ ├── colorize.ts │ └── devConsole.ts │ ├── cookies │ ├── client.ts │ ├── server.ts │ └── types.ts │ ├── hooks │ ├── useInvalidateQuery.ts │ ├── useOnDidMount.ts │ ├── useOutsideClick.ts │ ├── useRefetchQuery.ts │ └── useResizeObserver.ts │ ├── lodash │ ├── __tests__ │ │ └── lodash.spec.ts │ └── index.ts │ ├── queue │ ├── __tests__ │ │ └── queue.spec.ts │ └── index.ts │ ├── tests │ └── wait.ts │ ├── timer │ ├── __tests__ │ │ └── timer.spec.ts │ └── index.ts │ └── types │ └── index.ts ├── tools ├── removeOldFiles.js └── setupChaiDomAsserions.js ├── tsconfig.json └── webpack ├── client.ts ├── plugins └── dependencyManager │ └── plugin.ts ├── server.ts ├── universal.ts └── utils ├── isProduction.ts ├── merge.ts └── pino.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .idea/ 3 | .vscode 4 | .DS_Store 5 | .nyc_output 6 | .tsc_incremental_output 7 | .env 8 | .cache 9 | 10 | node_modules 11 | npm-debug.log 12 | 13 | build 14 | docs 15 | coverage 16 | 17 | .env.local 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,ts,tsx,css,package.json}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | prettier.config.js 3 | tsconfig.json 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log* 4 | 5 | # Diagnostic reports (https://nodejs.org/api/report.html) 6 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | *.lcov 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Dependency directories 25 | node_modules/ 26 | 27 | # Optional npm cache directory 28 | .npm 29 | 30 | # Optional eslint cache 31 | .eslintcache 32 | 33 | # dotenv environment variables file 34 | .env 35 | .env.test 36 | 37 | .cache 38 | .tsc_incremental_output 39 | 40 | *.cpuprofile 41 | 42 | build 43 | 44 | .DS_Store 45 | 46 | .env.local 47 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "check-coverage": true, 3 | "statements": 10, 4 | "branches": 10, 5 | "functions": 10, 6 | "lines": 10, 7 | "extension": [ 8 | ".ts", 9 | ".tsx" 10 | ], 11 | "include": [ 12 | "src/**/*.ts", 13 | "src/**/*.tsx" 14 | ], 15 | "exclude": [ 16 | "*.d.ts", 17 | "src/**/types.ts", 18 | "src/**/*.types.ts", 19 | "src/**/__tests__/**", 20 | "src/tests/**" 21 | ], 22 | "require": [ 23 | "ts-node/register", 24 | "./tools/setupChaiDomAsserions.js" 25 | ], 26 | "reporter": [ 27 | "text-summary", 28 | "html" 29 | ], 30 | "all": true, 31 | "cache": true 32 | } 33 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "typescript.tsdk": "./node_modules/typescript/lib", 4 | "editor.formatOnSave": true, 5 | "prettier.singleQuote": true, 6 | "prettier.trailingComma": "all", 7 | "prettier.arrowParens": "always", 8 | "prettier.printWidth": 106, 9 | "prettier.semi": true, 10 | "eslint.enable": true, 11 | "eslint.validate": [ 12 | "typescript", 13 | "typescriptreact" 14 | ], 15 | "eslint.alwaysShowStatus": true 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Artem Malko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | arrowParens: 'always', 5 | printWidth: 105, 6 | parser: 'typescript', 7 | semi: true, 8 | }; 9 | -------------------------------------------------------------------------------- /project.d.ts: -------------------------------------------------------------------------------- 1 | // Global object / window variables 2 | interface Window { 3 | __initialRouterState: any; 4 | __REDUX_DEVTOOLS_EXTENSION__: any; 5 | __application_cfg: any; 6 | onunhandledrejection: (error: PromiseRejectionEvent) => void; 7 | 8 | __staticResourcesPathMapping: { 9 | pathMapping: Record; 10 | inlineContent: string; 11 | }; 12 | __session: object; 13 | } 14 | 15 | declare namespace Express { 16 | interface Request { 17 | parsedUA: import('express-useragent').Details; 18 | clientIp: string; 19 | isSearchBot: boolean; 20 | searchBotData?: { 21 | botName: 'google' | 'yandex' | 'bing' | 'mail'; 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artem-malko/react-ssr-template/b6df9e3420483e6ab756090855ed520731c5d056/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artem-malko/react-ssr-template/b6df9e3420483e6ab756090855ed520731c5d056/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artem-malko/react-ssr-template/b6df9e3420483e6ab756090855ed520731c5d056/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/api/getNewsItemById.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from 'application/shared/lib/api'; 2 | 3 | import { NewsItem } from '../types'; 4 | 5 | type ApiParams = { id: number }; 6 | 7 | type ApiResponse = NewsItem; 8 | 9 | export const getNewsItemByIdApi = createApi(({ id }, { config, request }) => { 10 | const url = `${config.hackerNewsApiUrl}/item/${id}`; 11 | 12 | return request(url, { 13 | method: 'get', 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/api/getNewsList.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from 'application/shared/lib/api'; 2 | 3 | import { NewsItem } from '../types'; 4 | 5 | type ApiParams = { page: number }; 6 | 7 | type ApiResponse = NewsItem[]; 8 | 9 | export const getNewsListApi = createApi(({ page }, { config, request }) => { 10 | const url = `${config.hackerNewsApiUrl}/news?page=${page}`; 11 | 12 | return request(url, { 13 | method: 'get', 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/index.tsx: -------------------------------------------------------------------------------- 1 | export type { NewsItem } from './types'; 2 | 3 | export { NewsItem as NewsItemComp } from './ui/item'; 4 | 5 | export { useNewsItemById } from './model/fetch/useNewsItemById'; 6 | export { useInfinityNewsList } from './model/fetch/useInfinityNewsList'; 7 | export { usePaginatedNewsList } from './model/fetch/usePaginatedNewsList'; 8 | 9 | export { useInvalidateAllNewsQueries } from './model/invalidate/useInvalidateAllNewsQueries'; 10 | 11 | export { useRefetchAllNewsQueries } from './model/refetch/useRefetchAllNewsQueries'; 12 | 13 | export { getNewsItemDataFromCache } from './model/cache/getNewsItemDataFromCache'; 14 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/model/cache/getNewsItemDataFromCache.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | import { NewsItem } from '../../types'; 4 | import { newsQueryKeys } from '../common'; 5 | 6 | type Params = { 7 | queryClient: QueryClient; 8 | newsItemId: number; 9 | }; 10 | /** 11 | * Get newsItem data from a query cache 12 | */ 13 | export function getNewsItemDataFromCache({ queryClient, newsItemId }: Params) { 14 | return queryClient.getQueryData( 15 | newsQueryKeys.byId({ 16 | newsItemId, 17 | }), 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/model/common.ts: -------------------------------------------------------------------------------- 1 | import { UseInfinityNewsParams } from './fetch/useInfinityNewsList'; 2 | import { UseNewsItemParams } from './fetch/useNewsItemById'; 3 | import { UsePaginatedNewsParams } from './fetch/usePaginatedNewsList'; 4 | 5 | export const newsQueryKeys = { 6 | all: () => ['news'] as const, 7 | byId: (params: UseNewsItemParams) => [...newsQueryKeys.all(), 'byId', params] as const, 8 | allLists: () => [...newsQueryKeys.all(), 'list'] as const, 9 | allPaginatedLists: () => [...newsQueryKeys.allLists(), 'paginated'] as const, 10 | paginatedListByParams: (params: UsePaginatedNewsParams) => 11 | [...newsQueryKeys.allPaginatedLists(), params] as const, 12 | allInfinityLists: () => [...newsQueryKeys.allLists(), 'infinity'] as const, 13 | infinityListByParams: (params: UseInfinityNewsParams) => 14 | [...newsQueryKeys.allInfinityLists(), params] as const, 15 | }; 16 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/model/fetch/useInfinityNewsList.ts: -------------------------------------------------------------------------------- 1 | import { useAppSuspenseInfiniteQuery } from 'application/shared/hooks/useAppSuspenseInfiniteQuery'; 2 | import { useApi } from 'application/shared/lib/api'; 3 | 4 | import { getNewsListApi } from '../../api/getNewsList'; 5 | import { newsQueryKeys } from '../common'; 6 | 7 | export type UseInfinityNewsParams = { 8 | initialPage: number; 9 | }; 10 | 11 | export const useInfinityNewsList = (params: UseInfinityNewsParams) => { 12 | const getNewsList = useApi(getNewsListApi); 13 | 14 | return useAppSuspenseInfiniteQuery({ 15 | queryKey: newsQueryKeys.infinityListByParams(params), 16 | queryFn: ({ pageParam }) => getNewsList({ page: pageParam }), 17 | 18 | staleTime: Infinity, 19 | getNextPageParam: (_, pages) => params.initialPage + pages.length, 20 | initialPageParam: params.initialPage, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/model/fetch/useNewsItemById.ts: -------------------------------------------------------------------------------- 1 | import { useAppSuspenseQuery } from 'application/shared/hooks/useAppSuspenseQuery'; 2 | import { useApi } from 'application/shared/lib/api'; 3 | 4 | import { getNewsItemByIdApi } from '../../api/getNewsItemById'; 5 | import { NewsItem } from '../../types'; 6 | import { newsQueryKeys } from '../common'; 7 | 8 | export type UseNewsItemParams = { 9 | newsItemId: number; 10 | initialData?: NewsItem; 11 | }; 12 | export const useNewsItemById = (params: UseNewsItemParams) => { 13 | const getNewsItemById = useApi(getNewsItemByIdApi); 14 | 15 | return useAppSuspenseQuery({ 16 | queryKey: newsQueryKeys.byId(params), 17 | queryFn: () => getNewsItemById({ id: params.newsItemId }), 18 | 19 | staleTime: Infinity, 20 | initialData: params.initialData, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/model/fetch/usePaginatedNewsList.ts: -------------------------------------------------------------------------------- 1 | import { useAppSuspenseQuery } from 'application/shared/hooks/useAppSuspenseQuery'; 2 | import { useApi } from 'application/shared/lib/api'; 3 | 4 | import { getNewsListApi } from '../../api/getNewsList'; 5 | import { newsQueryKeys } from '../common'; 6 | 7 | export type UsePaginatedNewsParams = { 8 | page: number; 9 | }; 10 | 11 | export const usePaginatedNewsList = (params: UsePaginatedNewsParams) => { 12 | const getNewsList = useApi(getNewsListApi); 13 | 14 | return useAppSuspenseQuery({ 15 | queryKey: newsQueryKeys.paginatedListByParams(params), 16 | queryFn: async () => { 17 | // A simple fake latency for the requests from server side 18 | await new Promise((resolve) => setTimeout(resolve, params.page % 2 ? 4000 : 0)); 19 | 20 | return getNewsList({ page: params.page }); 21 | }, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/model/invalidate/useInvalidateAllNewsQueries.ts: -------------------------------------------------------------------------------- 1 | import { useInvalidateQuery } from 'lib/hooks/useInvalidateQuery'; 2 | 3 | import { newsQueryKeys } from '../common'; 4 | 5 | export const useInvalidateAllNewsQueries = () => { 6 | const invalidateQuery = useInvalidateQuery(); 7 | 8 | return () => 9 | invalidateQuery({ 10 | queryKey: newsQueryKeys.all(), 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/model/refetch/useRefetchAllNewsQueries.ts: -------------------------------------------------------------------------------- 1 | import { useRefetchQuery } from 'lib/hooks/useRefetchQuery'; 2 | 3 | import { newsQueryKeys } from '../common'; 4 | 5 | export const useRefetchAllNewsQueries = () => { 6 | const refetchQuery = useRefetchQuery(); 7 | 8 | return () => 9 | refetchQuery({ 10 | queryKey: newsQueryKeys.all(), 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/types.ts: -------------------------------------------------------------------------------- 1 | export type NewsItem = { 2 | id: number; 3 | title: string; 4 | user: string; 5 | time: number; 6 | time_ago: string; 7 | url: string; 8 | }; 9 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/ui/item/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | export const styles = createStyles({ 4 | root: { 5 | padding: 5, 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /src/application/entities/domain/news/ui/item/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import { useStyles } from 'framework/public/styles'; 4 | 5 | import { styles } from './index.css'; 6 | import { useNewsItemById } from '../../model/fetch/useNewsItemById'; 7 | 8 | export const NewsItem = memo<{ newsItemId: number }>(({ newsItemId }) => { 9 | const css = useStyles(styles); 10 | const newsItem = useNewsItemById({ newsItemId }); 11 | 12 | return ( 13 |
14 |

NewsITEM Component

15 | 16 | 19 | 20 | {newsItem.isSuccess && ( 21 | <> 22 |
id: {newsItem.data.id}
23 |
title: {newsItem.data.title}
24 | 25 | )} 26 |
27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/api/deleteUser.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from 'application/shared/lib/api'; 2 | 3 | import { baseRequestParams } from './shared'; 4 | 5 | type ApiParams = { id: string }; 6 | 7 | type ApiResponse = { 8 | data: { 9 | id: string; 10 | }; 11 | }; 12 | 13 | export const deleteUserApi = createApi(({ id }, { config, request }) => { 14 | return request(`${config.fakeCrudApi}/users/${id}`, { 15 | method: 'delete', 16 | ...baseRequestParams, 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/api/getUserById.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from 'application/shared/lib/api'; 2 | 3 | import { baseRequestParams } from './shared'; 4 | import { User } from '../types'; 5 | 6 | type ApiParams = { id: string }; 7 | 8 | type ApiResponse = { 9 | data: { 10 | user: User; 11 | }; 12 | }; 13 | 14 | export const getUserByIdApi = createApi(({ id }, { config, request }) => { 15 | return request(`${config.fakeCrudApi}/users/${id}`, { 16 | method: 'get', 17 | ...baseRequestParams, 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/api/getUserList.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from 'application/shared/lib/api'; 2 | 3 | import { baseRequestParams } from './shared'; 4 | import { User, UserStatus } from '../types'; 5 | 6 | type ApiParams = { page: number; status?: UserStatus[] }; 7 | 8 | type ApiResponse = { 9 | data: { 10 | users: User[]; 11 | total: number; 12 | }; 13 | }; 14 | 15 | export const getUserListApi = createApi((params, { config, request }) => { 16 | const { page, status } = params; 17 | const limit = 10; 18 | const offset = (page - 1) * limit; 19 | const URLWithoutStatus = `${config.fakeCrudApi}/users?limit=${limit}&offset=${offset}`; 20 | 21 | return request( 22 | status?.length 23 | ? `${URLWithoutStatus}&${status.map((s) => `status=${s}`).join('&')}` 24 | : URLWithoutStatus, 25 | { 26 | method: 'get', 27 | ...baseRequestParams, 28 | }, 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/api/patchUser.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from 'application/shared/lib/api'; 2 | 3 | import { baseRequestParams } from './shared'; 4 | import { User } from '../types'; 5 | 6 | type ApiParams = { user: Partial & { id: string } }; 7 | 8 | type ApiResponse = { 9 | data: { 10 | id: string; 11 | }; 12 | }; 13 | 14 | export const patchUserApi = createApi(({ user }, { config, request }) => { 15 | return request(`${config.fakeCrudApi}/users/${user.id}`, { 16 | method: 'patch', 17 | body: user, 18 | ...baseRequestParams, 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/api/postUser.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from 'application/shared/lib/api'; 2 | 3 | import { baseRequestParams } from './shared'; 4 | import { User } from '../types'; 5 | 6 | type ApiParams = { user: Omit }; 7 | 8 | type ApiResponse = { 9 | data: { 10 | id: string; 11 | }; 12 | }; 13 | 14 | export const postUserApi = createApi(({ user }, { config, request }) => { 15 | return request(`${config.fakeCrudApi}/users`, { 16 | method: 'post', 17 | body: user, 18 | ...baseRequestParams, 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/api/shared.ts: -------------------------------------------------------------------------------- 1 | export const baseRequestParams: Record<'credentials', RequestCredentials> = { 2 | credentials: 'same-origin', 3 | }; 4 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/index.tsx: -------------------------------------------------------------------------------- 1 | export { useUserList, useUserListOptimisticUpdater } from './model/fetch/useUserList'; 2 | export { useUserById, useUserByIdQueryFetcher } from './model/fetch/useUserById'; 3 | 4 | export { useInvalidateAllUserQueries } from './model/invalidate/useInvalidateAllUserQueries'; 5 | 6 | export { useRefetchAllUserQueries } from './model/refetch/useRefetchAllUserQueries'; 7 | 8 | export { useDeleteUser } from './model/mutate/useDeleteUser'; 9 | export { useEditUser } from './model/mutate/useEditUser'; 10 | export { useAddUser } from './model/mutate/useAddUser'; 11 | 12 | export type { User, UserStatus } from './types'; 13 | 14 | export { UserStatus as UserStatusComponent } from './ui/status'; 15 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/model/common.ts: -------------------------------------------------------------------------------- 1 | import { UserByIdParams } from './fetch/useUserById'; 2 | import { UseUserListParams } from './fetch/useUserList'; 3 | 4 | export const userQueryKeys = { 5 | all: () => ['users'] as const, 6 | byId: (params: UserByIdParams) => [...userQueryKeys.all(), 'byId', params] as const, 7 | allLists: () => [...userQueryKeys.all(), 'list'] as const, 8 | listByParams: (params: UseUserListParams) => [...userQueryKeys.allLists(), params] as const, 9 | }; 10 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/model/invalidate/useInvalidateAllUserQueries.ts: -------------------------------------------------------------------------------- 1 | import { useInvalidateQuery } from 'lib/hooks/useInvalidateQuery'; 2 | 3 | import { userQueryKeys } from '../common'; 4 | 5 | export const useInvalidateAllUserQueries = () => { 6 | const invalidateQuery = useInvalidateQuery(); 7 | 8 | return () => 9 | invalidateQuery({ 10 | queryKey: userQueryKeys.all(), 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/model/mutate/useAddUser.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query'; 2 | 3 | import { useApi } from 'application/shared/lib/api'; 4 | 5 | import { postUserApi } from '../../api/postUser'; 6 | import { UserStatus } from '../../types'; 7 | 8 | export const useAddUser = () => { 9 | const postUser = useApi(postUserApi); 10 | 11 | return useMutation({ 12 | mutationFn: (userToAdd: { name: string; status: UserStatus }) => { 13 | return postUser({ 14 | user: userToAdd, 15 | }); 16 | }, 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/model/mutate/useDeleteUser.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query'; 2 | 3 | import { useApi } from 'application/shared/lib/api'; 4 | 5 | import { deleteUserApi } from '../../api/deleteUser'; 6 | 7 | export const useDeleteUser = () => { 8 | const deleteUser = useApi(deleteUserApi); 9 | 10 | return useMutation({ 11 | mutationFn: (params: { userId: string; name: string }) => { 12 | return deleteUser({ id: params.userId }); 13 | }, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/model/mutate/useEditUser.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query'; 2 | 3 | import { useApi } from 'application/shared/lib/api'; 4 | 5 | import { patchUserApi } from '../../api/patchUser'; 6 | import { UserStatus } from '../../types'; 7 | 8 | export const useEditUser = () => { 9 | const patchUser = useApi(patchUserApi); 10 | 11 | return useMutation({ 12 | mutationFn: (userToUpdate: { id: string; name: string; status: UserStatus }) => { 13 | return patchUser({ 14 | user: userToUpdate, 15 | }); 16 | }, 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/model/refetch/useRefetchAllUserQueries.ts: -------------------------------------------------------------------------------- 1 | import { useRefetchQuery } from 'lib/hooks/useRefetchQuery'; 2 | 3 | import { userQueryKeys } from '../common'; 4 | 5 | export const useRefetchAllUserQueries = () => { 6 | const refetchQuery = useRefetchQuery(); 7 | 8 | return () => 9 | refetchQuery({ 10 | queryKey: userQueryKeys.all(), 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/types.ts: -------------------------------------------------------------------------------- 1 | export type UserStatus = 'active' | 'banned' | 'inactive'; 2 | 3 | export type User = { 4 | id: string; 5 | name: string; 6 | status: UserStatus; 7 | }; 8 | -------------------------------------------------------------------------------- /src/application/entities/domain/user/ui/status/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useMemo } from 'react'; 2 | 3 | import { UserStatus as UserStatusType } from '../../types'; 4 | 5 | type Props = { 6 | status: UserStatusType; 7 | }; 8 | 9 | export const UserStatus = memo(({ status }) => { 10 | const statusIcon = useMemo(() => { 11 | switch (status) { 12 | case 'active': 13 | return '🟢'; 14 | case 'inactive': 15 | return '🟠'; 16 | case 'banned': 17 | return '🔴'; 18 | } 19 | }, [status]); 20 | 21 | return
{statusIcon}
; 22 | }); 23 | -------------------------------------------------------------------------------- /src/application/entities/ui/navigation/hooks/useActivePage.ts: -------------------------------------------------------------------------------- 1 | import { useAnyActivePage } from 'framework/public/universal'; 2 | 3 | import { Page } from 'application/pages/shared'; 4 | 5 | /** 6 | * Returns an active page, just a wrapper around useAnyActivePage with a binded Page type 7 | */ 8 | export const useActivePage = () => { 9 | return useAnyActivePage(); 10 | }; 11 | -------------------------------------------------------------------------------- /src/application/entities/ui/navigation/hooks/useNavigate.ts: -------------------------------------------------------------------------------- 1 | import { useCommonNavigate } from 'framework/public/universal'; 2 | 3 | import { Page } from 'application/pages/shared'; 4 | 5 | /** 6 | * Just a wrapper around useCommonNavigate with a binded Page type 7 | */ 8 | export const useNavigate = () => { 9 | return useCommonNavigate(); 10 | }; 11 | -------------------------------------------------------------------------------- /src/application/entities/ui/navigation/hooks/useURLQuery.ts: -------------------------------------------------------------------------------- 1 | import { useCommonURLQuery } from 'framework/public/universal'; 2 | 3 | import { UnwrapReadonlyArrayElement } from 'lib/types'; 4 | 5 | export const allowedURLQueryKeys = [ 6 | 'utm_media', 7 | 'utm_source', 8 | 'utm_campaign', 9 | 'test_mode_attr', 10 | 'render', 11 | ] as const; 12 | export type AllowedURLQueryKeys = UnwrapReadonlyArrayElement; 13 | 14 | /** 15 | * Just a wrapper around useCommonURLQuery with a binded QueryKeys type 16 | */ 17 | export const useURLQuery = () => { 18 | return useCommonURLQuery(); 19 | }; 20 | -------------------------------------------------------------------------------- /src/application/entities/ui/navigation/index.tsx: -------------------------------------------------------------------------------- 1 | export { Link } from './ui/link'; 2 | 3 | export { CompileAppURLContext } from './ui/link/context'; 4 | 5 | export { useActivePage } from './hooks/useActivePage'; 6 | export { useNavigate } from './hooks/useNavigate'; 7 | export { useURLQuery, type AllowedURLQueryKeys, allowedURLQueryKeys } from './hooks/useURLQuery'; 8 | -------------------------------------------------------------------------------- /src/application/entities/ui/navigation/ui/link/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import { AnyAppContext } from 'framework/public/types'; 4 | 5 | export const CompileAppURLContext = createContext<(appContext: AnyAppContext) => string>(() => { 6 | return '/'; 7 | }); 8 | -------------------------------------------------------------------------------- /src/application/entities/ui/navigation/ui/link/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | import { colors } from 'application/shared/styles/shared'; 4 | 5 | export const styles = createStyles({ 6 | link: { 7 | textDecoration: 'underline', 8 | 9 | _t_default: { 10 | color: colors.teal.base(), 11 | 12 | ':focus': { 13 | outlineColor: colors.teal.light(), 14 | }, 15 | 16 | ':hover': { 17 | color: colors.teal.dark(), 18 | }, 19 | }, 20 | 21 | _no_link: { 22 | textDecoration: 'none', 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/application/entities/ui/userForm/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect, useState } from 'react'; 2 | 3 | import { UserStatus } from 'application/entities/domain/user'; 4 | 5 | type Props = { 6 | onSubmit: (name: string, userStatus: UserStatus) => void; 7 | resetOnSubmit?: boolean; 8 | initialName?: string; 9 | initialStatus?: UserStatus; 10 | }; 11 | export const UserForm = memo( 12 | ({ onSubmit, initialName = '', initialStatus = 'active', resetOnSubmit = false }) => { 13 | const [name, setName] = useState(initialName); 14 | const [status, setStatus] = useState(initialStatus); 15 | 16 | useEffect(() => { 17 | setName(initialName); 18 | }, [initialName]); 19 | 20 | useEffect(() => { 21 | setStatus(initialStatus); 22 | }, [initialStatus]); 23 | 24 | return ( 25 |
{ 27 | e.preventDefault(); 28 | onSubmit(name, status); 29 | 30 | if (resetOnSubmit) { 31 | setName(''); 32 | setStatus('active'); 33 | } 34 | }} 35 | > 36 | 37 |
38 | setName(e.target.value)} required /> 39 |
40 |
41 | 42 |
43 | 48 |
49 |
50 | 51 |
52 | ); 53 | }, 54 | ); 55 | UserForm.displayName = 'UserForm'; 56 | -------------------------------------------------------------------------------- /src/application/entry/common/metadata/getTitle.ts: -------------------------------------------------------------------------------- 1 | import { GetTitle } from 'framework/public/universal'; 2 | 3 | import { getTitle as getTitleForErrorPage } from 'application/pages/error'; 4 | import { getTitle as getTitleForNewsPage } from 'application/pages/news'; 5 | import { getTitle as getTitleForNewsItemPage } from 'application/pages/newsItem'; 6 | import { getTitle as getTitleForRootPage } from 'application/pages/root'; 7 | import { Page } from 'application/pages/shared'; 8 | import { getTitle as getTitleForUsersPage } from 'application/pages/users'; 9 | 10 | export const getTitle: GetTitle = (params) => { 11 | const { page } = params; 12 | 13 | switch (page.name) { 14 | case 'root': 15 | return getTitleForRootPage({ 16 | ...params, 17 | page, 18 | }); 19 | case 'news': 20 | return getTitleForNewsPage({ 21 | ...params, 22 | page, 23 | }); 24 | case 'newsItem': 25 | return getTitleForNewsItemPage({ 26 | ...params, 27 | page, 28 | }); 29 | case 'users': 30 | return getTitleForUsersPage({ 31 | ...params, 32 | page, 33 | }); 34 | case 'error': 35 | return getTitleForErrorPage({ 36 | ...params, 37 | page, 38 | }); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/application/entry/common/react/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | const toastContainerMaxWith = 420; 4 | const toastContainerSideGap = 10; 5 | 6 | export const styles = createStyles({ 7 | toastContainer: { 8 | position: 'fixed', 9 | top: 10, 10 | insetInlineEnd: toastContainerSideGap, 11 | width: '100%', 12 | maxWidth: toastContainerMaxWith, 13 | 14 | // If a screen width is 420px + 10px - 1px and lower — we do not need 15 | // a gap between screen and toastsContainer, cause where is no enough space 16 | // for that container + the gap 17 | [`@media screen and (max-width: ${toastContainerMaxWith + toastContainerSideGap - 1}px)`]: { 18 | top: 0, 19 | insetInlineEnd: 0, 20 | }, 21 | }, 22 | 23 | popupContainer: { 24 | position: 'fixed', 25 | top: 0, 26 | right: 0, 27 | bottom: 0, 28 | left: 0, 29 | pointerEvents: 'none', 30 | 31 | '& > *': { 32 | pointerEvents: 'auto', 33 | }, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/application/entry/server/middlewares/UAParser.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import useragent from 'express-useragent'; 3 | 4 | export function UAParser(mutableReq: Request, _res: Response, next: NextFunction) { 5 | // Default will be Chrome on Windows 6 | const ua = 7 | mutableReq.get('User-Agent') || 8 | 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36'; 9 | 10 | mutableReq.parsedUA = useragent.parse(ua); 11 | 12 | return next(); 13 | } 14 | -------------------------------------------------------------------------------- /src/application/features/addUser/index.tsx: -------------------------------------------------------------------------------- 1 | export { AddUser } from './ui/addUser'; 2 | -------------------------------------------------------------------------------- /src/application/features/addUser/ui/addUser.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import { useInvalidateAllUserQueries, useAddUser } from 'application/entities/domain/user'; 4 | import { UserForm } from 'application/entities/ui/userForm'; 5 | 6 | import { useGlassEffect } from 'application/shared/kit/glass'; 7 | 8 | export const AddUser = memo(() => { 9 | const { mutate: addUser, isPending: isMutationInProgress } = useAddUser(); 10 | 11 | const invalidateAllUserQueries = useInvalidateAllUserQueries(); 12 | 13 | useGlassEffect(isMutationInProgress, 'not_existed_glass_boundary'); 14 | 15 | return ( 16 |
24 |

Add user

25 |
26 | { 29 | addUser( 30 | { name, status }, 31 | { 32 | onSuccess() { 33 | invalidateAllUserQueries(); 34 | }, 35 | }, 36 | ); 37 | }} 38 | /> 39 |
40 | ); 41 | }); 42 | AddUser.displayName = 'AddUser'; 43 | -------------------------------------------------------------------------------- /src/application/features/development/index.tsx: -------------------------------------------------------------------------------- 1 | export { DevMenu } from './ui/devMenu'; 2 | -------------------------------------------------------------------------------- /src/application/features/development/readme.md: -------------------------------------------------------------------------------- 1 | All components from this dir are used for development only! 2 | -------------------------------------------------------------------------------- /src/application/features/development/ui/devMenu/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | import { colors } from 'application/shared/styles/shared'; 4 | 5 | export const styles = createStyles({ 6 | root: { 7 | position: 'relative', 8 | zIndex: 1, 9 | height: 50, 10 | }, 11 | 12 | menu: { 13 | position: 'fixed', 14 | top: 0, 15 | left: 0, 16 | right: 0, 17 | padding: 10, 18 | display: 'flex', 19 | background: colors.black.base(), 20 | color: colors.white.base(), 21 | }, 22 | 23 | link: { 24 | cursor: 'pointer', 25 | paddingInlineEnd: 10, 26 | paddingInlineStart: 10, 27 | fontSize: 18, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/application/features/development/ui/projectInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, version } from 'react'; 2 | 3 | import { useActivePage, useURLQuery } from 'application/entities/ui/navigation'; 4 | 5 | import { SourceSpoiler } from '../sourceSpoiler'; 6 | 7 | export const ProjectInfo = memo(() => { 8 | const page = useActivePage(); 9 | const { URLQueryParams } = useURLQuery(); 10 | 11 | return ( 12 |
15 |
16 | React version is: {version} 17 |
18 |
QueryString is: {JSON.stringify(URLQueryParams)}
19 |
20 | Current page: {page.name}. Click to «Show source» to get all data! 21 |
22 |
23 | 24 |
25 |
26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /src/application/features/development/ui/sourceSpoiler/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import { FadeIn } from 'application/shared/kit/fadeIn'; 4 | import { Spoiler } from 'application/shared/kit/spoiler'; 5 | 6 | type Props = { 7 | source: Record; 8 | }; 9 | export const SourceSpoiler = memo(({ source }) => { 10 | return ( 11 | 12 | {(isExpanded, toggle) => { 13 | return ( 14 |
15 |
16 | {isExpanded ? : } 17 |
18 | 19 | 20 | 21 | {source && 22 | Object.keys(source).map((key) => { 23 | return ( 24 | 25 | 28 | 29 | 30 | ); 31 | })} 32 | 33 |
26 | {key}  27 | {JSON.stringify(source[key], null, 4)}
34 |
35 |
36 | ); 37 | }} 38 |
39 | ); 40 | }); 41 | -------------------------------------------------------------------------------- /src/application/features/editUser/index.tsx: -------------------------------------------------------------------------------- 1 | export { UserEditor } from './ui/userEditor'; 2 | -------------------------------------------------------------------------------- /src/application/features/fakeAPIConfigurator/index.tsx: -------------------------------------------------------------------------------- 1 | export { FakeAPIConfigurator } from './ui/fakeAPIConfigurator'; 2 | -------------------------------------------------------------------------------- /src/application/features/newsItem/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | export const styles = createStyles({ 4 | root: { 5 | padding: 5, 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /src/application/features/newsItem/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect } from 'react'; 2 | 3 | import { useStyles } from 'framework/public/styles'; 4 | import { useAppLogger, usePlatformAPI } from 'framework/public/universal'; 5 | 6 | import { useNewsItemById } from 'application/entities/domain/news'; 7 | 8 | import { styles } from './index.css'; 9 | 10 | export const NewsItem = memo<{ newsItemId: number }>(({ newsItemId }) => { 11 | const css = useStyles(styles); 12 | const newsItem = useNewsItemById({ newsItemId }); 13 | const platformAPI = usePlatformAPI(); 14 | const { sendInfoLog } = useAppLogger(); 15 | 16 | useEffect(() => { 17 | if (newsItem.isSuccess) { 18 | sendInfoLog({ 19 | id: 'test_newsitem_render', 20 | message: 'NEWSITEM RENDERED ON CLIENT', 21 | }); 22 | 23 | platformAPI.cookies.set('lastOpenedNewsItemId', newsItem.data.id.toString()); 24 | } 25 | }, [platformAPI.cookies, newsItem.isSuccess, newsItem.data?.id, sendInfoLog]); 26 | 27 | return ( 28 |
29 |

NewsITEM Component

30 | 31 | 34 | 35 | {newsItem.isSuccess && ( 36 | <> 37 |
id: {newsItem.data.id}
38 |
title: {newsItem.data.title}
39 | 40 | )} 41 |
42 | ); 43 | }); 44 | export default NewsItem; 45 | -------------------------------------------------------------------------------- /src/application/features/newsList/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | export const styles = createStyles({ 4 | root: { 5 | paddingTop: 32, 6 | 7 | _big: { 8 | paddingTop: 50, 9 | }, 10 | 11 | '@media screen and (max-width: 1024px)': { 12 | _tablet: { 13 | paddingTop: 40, 14 | }, 15 | }, 16 | }, 17 | 18 | title: { 19 | _red: { 20 | color: 'red', 21 | }, 22 | }, 23 | 24 | list: { 25 | padding: 10, 26 | 27 | _red: { 28 | background: 'red', 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /src/application/features/newsList/item/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import { useStyles } from 'framework/public/styles'; 4 | 5 | import { styles } from './styles.css'; 6 | 7 | const Item = memo<{ title: string; onHover: (title: string) => void }>(({ title, onHover }) => { 8 | const css = useStyles(styles); 9 | 10 | console.log('render NewsList Item'); 11 | return ( 12 |
onHover(title)} className={css('item')}> 13 | {title} 14 |
15 | ); 16 | }); 17 | 18 | export default Item; 19 | -------------------------------------------------------------------------------- /src/application/features/newsList/item/styles.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | export const styles = createStyles({ 4 | item: { 5 | padding: '2px 0', 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /src/application/features/search/index.tsx: -------------------------------------------------------------------------------- 1 | export { Search } from './ui/search'; 2 | -------------------------------------------------------------------------------- /src/application/features/search/ui/search/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | export const styles = createStyles({ 4 | root: { 5 | padding: '10px 20px', 6 | outline: '1px solid violet', 7 | background: 'rgba(0,0,0,0.1)', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/application/features/search/ui/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState } from 'react'; 2 | 3 | import { useStyles } from 'framework/public/styles'; 4 | 5 | import { styles } from './index.css'; 6 | 7 | export const Search = memo(() => { 8 | const css = useStyles(styles); 9 | const [value, setValue] = useState(''); 10 | 11 | return ( 12 |
13 |

Search Component

14 | Try to input something before NewList will be ready. As you can see, Search component is ready to 15 | work, even other components are still in loading stage 16 |
17 |
18 | setValue(e.target.value)} defaultValue={value} /> 19 |
20 |
21 | Your input: {value} 22 |
23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /src/application/features/staticComponent/index.tsx: -------------------------------------------------------------------------------- 1 | export { StaticDataComponent } from './ui/staticDataComponent'; 2 | -------------------------------------------------------------------------------- /src/application/features/userList/index.tsx: -------------------------------------------------------------------------------- 1 | export { UserList } from './ui/userList'; 2 | -------------------------------------------------------------------------------- /src/application/pages/_internals/index.ts: -------------------------------------------------------------------------------- 1 | import { GetMetadata, Metadata } from 'framework/public/server'; 2 | import { bindRouteConfigToPathCreator, createRouteConfigCreator } from 'framework/public/universal'; 3 | 4 | import { Page } from 'application/pages/shared'; 5 | 6 | import { ErrorPage } from '../error'; 7 | 8 | export const createRouteConfig = createRouteConfigCreator(); 9 | export const bindRouteConfigToPath = bindRouteConfigToPathCreator(); 10 | 11 | export type GetMetadataForPage

= ( 12 | ...params: Parameters> 13 | ) => Promise>; 14 | -------------------------------------------------------------------------------- /src/application/pages/error/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import { RaiseError } from 'framework/public/universal'; 4 | 5 | import type { CommonPage } from 'application/pages/shared'; 6 | 7 | export interface ErrorPage extends CommonPage { 8 | name: 'error'; 9 | params: { 10 | code: number; 11 | }; 12 | } 13 | 14 | export { getTitle, getMetaData } from './metadata'; 15 | 16 | export default memo<{ page: ErrorPage }>( 17 | ({ 18 | page = { 19 | name: 'error', 20 | params: { 21 | code: 500, 22 | }, 23 | }, 24 | }) => { 25 | return ( 26 | <> 27 | 28 | Error Page {page.params.code} 29 | 30 | ); 31 | }, 32 | ); 33 | -------------------------------------------------------------------------------- /src/application/pages/error/metadata.ts: -------------------------------------------------------------------------------- 1 | import { GetTitle } from 'framework/public/universal'; 2 | 3 | import { ErrorPage } from '.'; 4 | import { GetMetadataForPage } from '../_internals'; 5 | 6 | export const getTitle: GetTitle = ({ page }) => { 7 | return `Error page title. Error code: ${page.params.code}`; 8 | }; 9 | 10 | export const getMetaData: GetMetadataForPage = async (params) => { 11 | return { 12 | title: getTitle(params), 13 | description: `Error page description: ${params.page.params.code}`, 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/application/pages/error/routing.ts: -------------------------------------------------------------------------------- 1 | import { ErrorPage } from '.'; 2 | import { createRouteConfig } from '../_internals'; 3 | 4 | export const errorPageRouteConfig = createRouteConfig({ 5 | mapURLParamsToPage: ({ code }) => ({ 6 | name: 'error', 7 | params: { 8 | code: parseInt(code, 10), 9 | }, 10 | }), 11 | mapPageToURLParams: ({ code }) => { 12 | return { 13 | path: { 14 | code: code.toString(), 15 | }, 16 | }; 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/application/pages/news/index.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | 3 | import { CommonPage } from 'application/pages/shared'; 4 | 5 | export interface NewsPage extends CommonPage { 6 | name: 'news'; 7 | params: { 8 | page: number; 9 | useInfinity?: boolean; 10 | }; 11 | } 12 | 13 | export const newsPageDefaultParams: NewsPage['params'] = { 14 | page: 1, 15 | useInfinity: false, 16 | }; 17 | 18 | export { getTitle, getMetaData } from './metadata'; 19 | 20 | export const NewsPage = lazy(() => import(/* webpackChunkName: "newsPage" */ './ui')); 21 | -------------------------------------------------------------------------------- /src/application/pages/news/metadata.ts: -------------------------------------------------------------------------------- 1 | import { GetTitle } from 'framework/public/universal'; 2 | 3 | import { NewsPage } from '.'; 4 | import { GetMetadataForPage } from '../_internals'; 5 | 6 | export const getTitle: GetTitle = ({ page }) => { 7 | return `News page title. Page: ${page.params.page}, useInfinity: ${page.params.useInfinity}`; 8 | }; 9 | 10 | export const getMetaData: GetMetadataForPage = async (params) => { 11 | return { 12 | title: getTitle(params), 13 | description: `News page description: ${params.page.params}`, 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/application/pages/news/routing.ts: -------------------------------------------------------------------------------- 1 | import { parsePageQueryParam } from 'application/shared/lib/routing'; 2 | 3 | import { NewsPage } from '.'; 4 | import { createRouteConfig } from '../_internals'; 5 | 6 | export const newsPageRouteConfig = createRouteConfig({ 7 | mapURLParamsToPage: (_, queryParams) => ({ 8 | name: 'news', 9 | params: { 10 | page: parsePageQueryParam(queryParams), 11 | useInfinity: !!queryParams['useInfinity'], 12 | }, 13 | }), 14 | mapPageToURLParams: ({ page, useInfinity }) => { 15 | return { 16 | query: { 17 | p: [page.toString()], 18 | useInfinity: useInfinity ? [''] : [], 19 | }, 20 | }; 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/application/pages/newsItem/index.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | 3 | import { CommonPage } from 'application/pages/shared'; 4 | 5 | export interface NewsItemPage extends CommonPage { 6 | name: 'newsItem'; 7 | params: { 8 | id: number; 9 | }; 10 | } 11 | 12 | export { getTitle, getMetaData } from './metadata'; 13 | 14 | export const NewsItemPage = lazy(() => import(/* webpackChunkName: "newsItemPage" */ './ui')); 15 | -------------------------------------------------------------------------------- /src/application/pages/newsItem/metadata.ts: -------------------------------------------------------------------------------- 1 | import { GetTitle } from 'framework/public/universal'; 2 | 3 | import { getNewsItemDataFromCache } from 'application/entities/domain/news'; 4 | 5 | import { NewsItemPage } from '.'; 6 | import { GetMetadataForPage } from '../_internals'; 7 | 8 | export const getTitle: GetTitle = ({ page }) => { 9 | return `NewsItem page title. Id: ${page.params.id}`; 10 | }; 11 | 12 | export const getMetaData: GetMetadataForPage = async (params) => { 13 | const newsItemData = getNewsItemDataFromCache({ 14 | queryClient: params.queryClient, 15 | newsItemId: params.page.params.id, 16 | }); 17 | 18 | return { 19 | title: getTitle(params), 20 | description: newsItemData 21 | ? 'NEWSITEM with name: ' + newsItemData.title 22 | : 'Example description for NewsItem page', 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/application/pages/newsItem/routing.ts: -------------------------------------------------------------------------------- 1 | import { NewsItemPage } from '.'; 2 | import { createRouteConfig } from '../_internals'; 3 | 4 | export const newsItemPageRouteConfig = createRouteConfig({ 5 | mapURLParamsToPage: ({ id: rawId }) => { 6 | const id = parseInt(rawId, 10); 7 | 8 | if (Number.isNaN(id)) { 9 | return { 10 | name: 'error', 11 | params: { code: 404 }, 12 | }; 13 | } 14 | 15 | return { 16 | name: 'newsItem', 17 | params: { 18 | id, 19 | }, 20 | }; 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/application/pages/newsItem/ui.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import NewsItem from 'application/features/newsItem'; 4 | 5 | import { NewsItemPage } from '.'; 6 | import { ReactQueryBoundary } from 'application/shared/lib/query'; 7 | 8 | export default memo<{ page: NewsItemPage }>( 9 | ({ 10 | page = { 11 | name: 'newsItem', 12 | params: { 13 | id: -1, 14 | }, 15 | }, 16 | }) => { 17 | return ( 18 | <> 19 |

news item with id {page.params.id}
20 | 21 | 22 | 23 | 24 | ); 25 | }, 26 | ); 27 | -------------------------------------------------------------------------------- /src/application/pages/root/index.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | 3 | import { CommonPage } from 'application/pages/shared'; 4 | 5 | export interface RootPage extends CommonPage { 6 | name: 'root'; 7 | } 8 | 9 | export { getTitle, getMetaData } from './metadata'; 10 | 11 | export const RootPage = lazy(() => import(/* webpackChunkName: "rootPage" */ './ui')); 12 | -------------------------------------------------------------------------------- /src/application/pages/root/metadata.ts: -------------------------------------------------------------------------------- 1 | import { GetTitle } from 'framework/public/universal'; 2 | 3 | import { RootPage } from '.'; 4 | import { GetMetadataForPage } from '../_internals'; 5 | 6 | export const getTitle: GetTitle = ({ URLQueryParams }) => { 7 | return `Root page title: ${URLQueryParams.toString()}`; 8 | }; 9 | 10 | export const getMetaData: GetMetadataForPage = async (params) => { 11 | return { 12 | title: getTitle(params), 13 | description: `Root page description`, 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/application/pages/root/routing.ts: -------------------------------------------------------------------------------- 1 | import { RootPage } from '.'; 2 | import { createRouteConfig } from '../_internals'; 3 | 4 | export const rootPageRouteConfig = createRouteConfig({ 5 | mapURLParamsToPage: () => ({ name: 'root' }), 6 | }); 7 | -------------------------------------------------------------------------------- /src/application/pages/users/index.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | 3 | import { CommonPage } from 'application/pages/shared'; 4 | 5 | import { UserStatus } from 'application/entities/domain/user'; 6 | 7 | export interface UsersPage extends CommonPage { 8 | name: 'users'; 9 | params: { 10 | page: number; 11 | filterStatus?: UserStatus[]; 12 | activeUserId?: string; 13 | }; 14 | } 15 | 16 | export const usersPageDefaultParams: UsersPage['params'] = { 17 | page: 1, 18 | filterStatus: undefined, 19 | activeUserId: undefined, 20 | }; 21 | 22 | export { getTitle, getMetaData } from './metadata'; 23 | 24 | export const UsersPage = lazy(() => import(/* webpackChunkName: "usersPage" */ './ui')); 25 | -------------------------------------------------------------------------------- /src/application/pages/users/metadata.ts: -------------------------------------------------------------------------------- 1 | import { GetTitle } from 'framework/public/universal'; 2 | 3 | import { UsersPage } from '.'; 4 | import { GetMetadataForPage } from '../_internals'; 5 | 6 | export const getTitle: GetTitle = ({ page }) => { 7 | return `User page title. Page: ${page.params.page}, filters: ${page.params.filterStatus}`; 8 | }; 9 | 10 | export const getMetaData: GetMetadataForPage = async (params) => { 11 | return { 12 | title: getTitle(params), 13 | description: `Users page description: ${params.page.params}`, 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/application/pages/users/routing.ts: -------------------------------------------------------------------------------- 1 | import type { URLQueryParams } from 'framework/public/types'; 2 | 3 | import { UserStatus } from 'application/entities/domain/user'; 4 | 5 | import { parsePageQueryParam } from 'application/shared/lib/routing'; 6 | 7 | import { UsersPage } from '.'; 8 | import { createRouteConfig } from '../_internals'; 9 | 10 | const filterQueryParamName = 'filter[status]'; 11 | 12 | export const usersPageRouteConfig = createRouteConfig({ 13 | mapURLParamsToPage: (_, queryParams) => ({ 14 | name: 'users', 15 | params: { 16 | page: parsePageQueryParam(queryParams), 17 | activeUserId: queryParams['userId'] && queryParams['userId'][0], 18 | filterStatus: parseFilterStatus(queryParams), 19 | }, 20 | }), 21 | mapPageToURLParams: ({ page, activeUserId, filterStatus }) => { 22 | return { 23 | query: { 24 | p: [page.toString()], 25 | userId: [activeUserId], 26 | 'filter[status]': filterStatus || [], 27 | }, 28 | }; 29 | }, 30 | }); 31 | 32 | function parseFilterStatus(queryParams: URLQueryParams): UserStatus[] { 33 | return (queryParams[filterQueryParamName] || []).reduce((mutableRes, param) => { 34 | switch (param) { 35 | case 'active': 36 | case 'banned': 37 | case 'inactive': 38 | mutableRes.push(param); 39 | break; 40 | default: 41 | return mutableRes; 42 | } 43 | 44 | return mutableRes; 45 | }, []); 46 | } 47 | -------------------------------------------------------------------------------- /src/application/shared/config/defaults/application.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '../types'; 2 | 3 | export const defaultApplicationConfig: ApplicationConfig = { 4 | networkTimeout: 10000, 5 | publicPath: '/public/', 6 | 7 | hackerNewsApiUrl: '//node-hnapi.herokuapp.com', 8 | fakeCrudApi: '/api/fakecrud', 9 | }; 10 | 11 | export const defaultServerApplicationConfig: ApplicationConfig = { 12 | ...defaultApplicationConfig, 13 | networkTimeout: 2000, 14 | fakeCrudApi: '//127.0.0.1:4000/api/fakecrud', 15 | }; 16 | 17 | export const defaultClientApplicationConfig: ApplicationConfig = { 18 | ...defaultApplicationConfig, 19 | networkTimeout: 10000, 20 | fakeCrudApi: '/api/fakecrud', 21 | }; 22 | -------------------------------------------------------------------------------- /src/application/shared/config/defaults/server.ts: -------------------------------------------------------------------------------- 1 | import { ServerConfig } from '../types'; 2 | 3 | export const defaultServerConfig: ServerConfig = { 4 | port: 4000, 5 | }; 6 | -------------------------------------------------------------------------------- /src/application/shared/config/hook.ts: -------------------------------------------------------------------------------- 1 | import { useAnyConfig } from 'framework/public/universal'; 2 | 3 | import { ApplicationConfig } from './types'; 4 | 5 | export const useConfig = () => { 6 | return useAnyConfig(); 7 | }; 8 | -------------------------------------------------------------------------------- /src/application/shared/config/index.ts: -------------------------------------------------------------------------------- 1 | export { defaultClientApplicationConfig, defaultServerApplicationConfig } from './defaults/application'; 2 | export { defaultServerConfig } from './defaults/server'; 3 | -------------------------------------------------------------------------------- /src/application/shared/config/types.ts: -------------------------------------------------------------------------------- 1 | import type { BaseApplicationConfig, BaseServerConfig } from 'framework/public/types'; 2 | 3 | // Can be used on server side only to configure a server, which serve application 4 | export interface ServerConfig extends BaseServerConfig { 5 | port: number; 6 | } 7 | 8 | // Can be used on client and server side to configure application 9 | export interface ApplicationConfig extends BaseApplicationConfig { 10 | networkTimeout: number; 11 | 12 | // HackerNews API URL 13 | hackerNewsApiUrl: string; 14 | 15 | // Fake API to demonstrate all @tanstack/react-querys advantages 16 | fakeCrudApi: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/application/shared/constants/cookies.ts: -------------------------------------------------------------------------------- 1 | import { CookieOptions } from 'express'; 2 | 3 | type CookieDescription = { 4 | name: string; 5 | options?: CookieOptions; 6 | }; 7 | 8 | export const useErrorsInFakeAPI: CookieDescription = { 9 | name: 'useErrorsInFakeAPI', 10 | 11 | options: { 12 | // 1 day 13 | maxAge: 24 * 60 * 60, 14 | }, 15 | }; 16 | 17 | export const useRandomLatencyInFakeAPI: CookieDescription = { 18 | name: 'useRandomLatencyInFakeAPI', 19 | options: { 20 | // 1 day 21 | maxAge: 24 * 60 * 60, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/application/shared/hooks/useAppSuspenseInfiniteQuery.ts: -------------------------------------------------------------------------------- 1 | import { QueryKey } from '@tanstack/react-query'; 2 | 3 | import type { ParsedError } from 'framework/public/types'; 4 | import { 5 | useAnyAppSuspenseInfiniteQuery, 6 | UseAnyAppSuspenseInfiniteQueryOptions, 7 | } from 'framework/public/universal'; 8 | 9 | /** 10 | * Just a wrapper around useAnyAppSuspenseInfiniteQuery, 11 | * which binds TError type 12 | */ 13 | export const useAppSuspenseInfiniteQuery = < 14 | TResult, 15 | TError extends ParsedError, 16 | QKey extends QueryKey, 17 | TPageParam, 18 | >( 19 | queryOptions: UseAnyAppSuspenseInfiniteQueryOptions, 20 | ) => useAnyAppSuspenseInfiniteQuery(queryOptions); 21 | -------------------------------------------------------------------------------- /src/application/shared/hooks/useAppSuspenseQuery.ts: -------------------------------------------------------------------------------- 1 | import { QueryKey } from '@tanstack/react-query'; 2 | 3 | import type { ParsedError } from 'framework/public/types'; 4 | import { UseAnyAppSuspenseQueryOptions, useAnyAppSuspenseQuery } from 'framework/public/universal'; 5 | 6 | /** 7 | * Just a wrapper around useAnyAppSuspenseQuery, which binds TError type 8 | */ 9 | export const useAppSuspenseQuery = ( 10 | queryOptions: UseAnyAppSuspenseQueryOptions, 11 | ) => useAnyAppSuspenseQuery(queryOptions); 12 | -------------------------------------------------------------------------------- /src/application/shared/hooks/useSomethingWentWrongToast.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useCallback } from 'react'; 2 | 3 | import { useToast } from '../kit/toast/infrastructure/hook'; 4 | 5 | export const useSomethingWentWrongToast = () => { 6 | const { showToast } = useToast(); 7 | 8 | return useCallback( 9 | (params: { description?: ReactNode; error?: Error }) => { 10 | const { description = 'Something went wrong!', error } = params; 11 | showToast({ 12 | body: () => ( 13 | <> 14 | {description} 15 | {error && ( 16 | <> 17 |
18 | {JSON.stringify(error)} 19 | 20 | )} 21 | 22 | ), 23 | }); 24 | }, 25 | [showToast], 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/application/shared/kit/fadeIn/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | export const styles = createStyles({ 4 | fade: { 5 | opacity: 0, 6 | transitionProperty: 'opacity', 7 | 8 | _shown: { 9 | opacity: 1, 10 | }, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /src/application/shared/kit/fadeIn/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, PropsWithChildren, useState, useEffect, useMemo } from 'react'; 2 | 3 | import { useStyles } from 'framework/public/styles'; 4 | 5 | import { midTransitionDuration } from 'application/shared/styles/shared'; 6 | 7 | import { styles } from './index.css'; 8 | 9 | type Props = { 10 | transitionDuration?: number; 11 | transitionDelay?: number; 12 | isShown: boolean; 13 | isInitiallyShown?: boolean; 14 | }; 15 | export const FadeIn = memo>( 16 | ({ 17 | transitionDuration = midTransitionDuration, 18 | transitionDelay = 0, 19 | isShown, 20 | children, 21 | isInitiallyShown, 22 | }) => { 23 | const [shouldAnimate, setSouldAnimate] = useState(false); 24 | const [internalIsInitiallyShown, setInternalIsInitiallyShown] = useState(isInitiallyShown); 25 | const css = useStyles(styles); 26 | const inlineStyles = useMemo(() => { 27 | if (internalIsInitiallyShown) { 28 | return {}; 29 | } 30 | 31 | return { 32 | transitionDuration: `${transitionDuration}ms`, 33 | transitionDelay: `${transitionDelay}ms`, 34 | }; 35 | }, [transitionDuration, transitionDelay, internalIsInitiallyShown]); 36 | 37 | useEffect(() => { 38 | setSouldAnimate(isShown); 39 | setInternalIsInitiallyShown(false); 40 | }, [isShown]); 41 | 42 | return ( 43 |
47 | {isShown && children} 48 |
49 | ); 50 | }, 51 | ); 52 | FadeIn.displayName = 'FadeIn'; 53 | -------------------------------------------------------------------------------- /src/application/shared/kit/flexbox/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, PropsWithChildren, Children } from 'react'; 2 | 3 | type Props = { 4 | children: React.ReactElement | Array>; 5 | flexDirection?: React.CSSProperties['flexDirection']; 6 | }; 7 | export const FlexBox = memo(({ children }) => { 8 | return ( 9 |
10 | {Children.map(children, (child) => { 11 | const isFlexItem = 12 | typeof child.type !== 'string' && 13 | 'displayName' in child.type && 14 | child.type.displayName === FlexItem.displayName; 15 | 16 | return isFlexItem ? child : {child}; 17 | })} 18 |
19 | ); 20 | }); 21 | FlexBox.displayName = 'flexBox'; 22 | 23 | type FlexItemProps = { 24 | flex?: React.CSSProperties['flex']; 25 | }; 26 | export const FlexItem = memo>(({ children, flex = '1 1 auto' }) => { 27 | return
{children}
; 28 | }); 29 | FlexItem.displayName = 'flexItem'; 30 | 31 | export const A = memo(() => { 32 | return ( 33 | 34 | qwe 35 | 36 |
asd
37 |
38 |
zxc
39 |
40 | ); 41 | }); 42 | -------------------------------------------------------------------------------- /src/application/shared/kit/glass/components/glass/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | import { colors, midTransitionDuration } from 'application/shared/styles/shared'; 4 | 5 | export const styles = createStyles({ 6 | root: { 7 | position: 'relative', 8 | isolation: 'isolate', 9 | }, 10 | 11 | content: { 12 | position: 'relative', 13 | zIndex: 1, 14 | }, 15 | 16 | glass: { 17 | position: 'absolute', 18 | top: 0, 19 | right: 0, 20 | bottom: 0, 21 | left: 0, 22 | zIndex: 2, 23 | backgroundColor: colors.white.base(0), 24 | transitionProperty: 'background-color', 25 | transitionDuration: `${midTransitionDuration}ms`, 26 | pointerEvents: 'none', 27 | 28 | _shown: { 29 | backgroundColor: colors.white.base(0.7), 30 | pointerEvents: 'auto', 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /src/application/shared/kit/glass/components/glass/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, PropsWithChildren } from 'react'; 2 | 3 | import { useStyles } from 'framework/public/styles'; 4 | 5 | import { styles } from './index.css'; 6 | 7 | type Props = { 8 | isShown: boolean; 9 | }; 10 | export const DefaultGlassUI = memo>(({ isShown, children }) => { 11 | const css = useStyles(styles); 12 | 13 | return ( 14 |
15 |
{children}
16 |
17 |
18 | ); 19 | }); 20 | DefaultGlassUI.displayName = 'DefaultGlassUI'; 21 | -------------------------------------------------------------------------------- /src/application/shared/kit/glass/constants.ts: -------------------------------------------------------------------------------- 1 | export const RootGlassBoundaryName = 'root'; 2 | -------------------------------------------------------------------------------- /src/application/shared/kit/glass/hook.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect, useRef } from 'react'; 2 | 3 | import { noopFunc } from 'lib/lodash'; 4 | 5 | import { GlassContext } from './context'; 6 | 7 | export const useToggleGlass = () => { 8 | const { showGlass } = useContext(GlassContext); 9 | const hideGlassFuncRef = useRef(noopFunc); 10 | 11 | return useCallback( 12 | (isLoading: boolean, glassName?: string) => { 13 | if (isLoading) { 14 | hideGlassFuncRef.current = showGlass(glassName); 15 | } else { 16 | hideGlassFuncRef.current(); 17 | } 18 | }, 19 | [showGlass], 20 | ); 21 | }; 22 | 23 | export const useGlassEffect = (needToShowGlass: boolean, glassName?: string) => { 24 | const { showGlass } = useContext(GlassContext); 25 | 26 | useEffect(() => { 27 | if (!needToShowGlass) { 28 | return; 29 | } 30 | 31 | const hideGlass = showGlass(glassName); 32 | 33 | return () => { 34 | hideGlass(); 35 | }; 36 | }, [needToShowGlass, glassName, showGlass]); 37 | }; 38 | -------------------------------------------------------------------------------- /src/application/shared/kit/glass/index.tsx: -------------------------------------------------------------------------------- 1 | export { GlassBoundary, useGlassContext } from './context'; 2 | 3 | export { RootGlassBoundaryName } from './constants'; 4 | 5 | export { useToggleGlass, useGlassEffect } from './hook'; 6 | -------------------------------------------------------------------------------- /src/application/shared/kit/lazy/index.tsx: -------------------------------------------------------------------------------- 1 | import { createLazyComponentLoader } from 'framework/public/universal'; 2 | 3 | export const Lazy = createLazyComponentLoader(); 4 | -------------------------------------------------------------------------------- /src/application/shared/kit/popover/container.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import { popoverContainerId } from './shared'; 4 | 5 | const popoverContainerStyles: React.CSSProperties = { 6 | pointerEvents: 'none', 7 | position: 'absolute', 8 | minHeight: '100%', 9 | top: 0, 10 | left: 0, 11 | right: 0, 12 | }; 13 | 14 | export const PopoverContainer = memo(() => { 15 | return
; 16 | }); 17 | PopoverContainer.displayName = 'PopoverContainer'; 18 | -------------------------------------------------------------------------------- /src/application/shared/kit/popover/index.tsx: -------------------------------------------------------------------------------- 1 | export { Popover, type Alignment, type Placement } from './popover'; 2 | 3 | export { PopoverContainer } from './container'; 4 | -------------------------------------------------------------------------------- /src/application/shared/kit/popover/shared.ts: -------------------------------------------------------------------------------- 1 | export const popoverContainerId = '__popoverContainerId'; 2 | -------------------------------------------------------------------------------- /src/application/shared/kit/popup/basePopup/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | import { colors } from 'application/shared/styles/shared'; 4 | 5 | export const styles = createStyles({ 6 | root: { 7 | position: 'relative', 8 | isolation: 'isolate', 9 | background: colors.white.base(), 10 | borderRadius: 4, 11 | height: '100%', 12 | overflow: 'hidden', 13 | }, 14 | 15 | closeWrapper: { 16 | position: 'sticky', 17 | top: 0, 18 | display: 'flex', 19 | justifyContent: 'flex-end', 20 | }, 21 | 22 | closeButton: { 23 | position: 'absolute', 24 | padding: 2, 25 | fontSize: '22px', 26 | lineHeight: '22px', 27 | cursor: 'pointer', 28 | background: 'radial-gradient(ellipse at center, rgba(255,255,255,1) 80%,rgba(255,255,255,0) 100%)', 29 | pointerEvents: 'auto', 30 | }, 31 | 32 | body: { 33 | display: 'flex', 34 | alignItems: 'center', 35 | justifyContent: 'center', 36 | height: '100%', 37 | width: '100%', 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/application/shared/kit/popup/basePopup/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, PropsWithChildren, useCallback } from 'react'; 2 | 3 | import { useStyles } from 'framework/public/styles'; 4 | 5 | import { ZIndexLayout } from 'application/shared/kit/zIndex'; 6 | 7 | import { styles } from './index.css'; 8 | import { usePopupActions } from '../infrastructure/hook'; 9 | 10 | type Props = { 11 | hideCloseButton?: boolean; 12 | popupId: string; 13 | }; 14 | export const BasePopup = memo>(({ children, hideCloseButton, popupId }) => { 15 | const css = useStyles(styles); 16 | const { closePopupById } = usePopupActions(); 17 | const onCloseClick = useCallback(() => { 18 | closePopupById(popupId); 19 | }, [closePopupById, popupId]); 20 | 21 | return ( 22 |
23 | 27 |
28 | ✖️ 29 |
30 |
31 | ) : ( 32 | <> 33 | ) 34 | } 35 | base={
{children}
} 36 | /> 37 |
38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /src/application/shared/kit/popup/index.tsx: -------------------------------------------------------------------------------- 1 | export { PopupControllerContext } from './infrastructure/context'; 2 | export { PopupController } from './infrastructure/controller'; 3 | 4 | export { Popup } from './popup'; 5 | export { BasePopup } from './basePopup'; 6 | 7 | export { usePopup, usePopupActions } from './infrastructure/hook'; 8 | -------------------------------------------------------------------------------- /src/application/shared/kit/popup/infrastructure/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import { PopupController } from './controller'; 4 | 5 | export const PopupControllerContext = createContext({} as PopupController); 6 | -------------------------------------------------------------------------------- /src/application/shared/kit/popup/infrastructure/controller.ts: -------------------------------------------------------------------------------- 1 | import { Popup } from '../types'; 2 | 3 | type Event = 4 | | { 5 | type: 'add'; 6 | popup: Popup; 7 | } 8 | | { 9 | type: 'remove'; 10 | popupId: string; 11 | } 12 | | { 13 | type: 'removeAll'; 14 | }; 15 | type Subscriber = (event: Event) => void; 16 | 17 | export class PopupController { 18 | private mutableSubscribers: Subscriber[] = []; 19 | 20 | public addPopup = (popup: Popup) => { 21 | this.mutableSubscribers.forEach((subscriber) => { 22 | subscriber({ 23 | type: 'add', 24 | popup, 25 | }); 26 | }); 27 | }; 28 | 29 | public removePopupById = (popupId: string) => { 30 | this.mutableSubscribers.forEach((subscriber) => { 31 | subscriber({ 32 | type: 'remove', 33 | popupId, 34 | }); 35 | }); 36 | }; 37 | 38 | public removeAll = () => { 39 | this.mutableSubscribers.forEach((subscriber) => { 40 | subscriber({ 41 | type: 'removeAll', 42 | }); 43 | }); 44 | }; 45 | 46 | public subscribeToChanges = (subscriber: Subscriber) => { 47 | this.mutableSubscribers.push(subscriber); 48 | 49 | const subscriberIndex = this.mutableSubscribers.length - 1; 50 | 51 | return () => { 52 | this.mutableSubscribers.splice(subscriberIndex, 1); 53 | }; 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/application/shared/kit/popup/infrastructure/hook.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useMemo } from 'react'; 2 | import { v4 } from 'uuid'; 3 | 4 | import { PopupControllerContext } from './context'; 5 | import { Popup } from '../types'; 6 | 7 | /** 8 | * Used to show/hide one concrete popup 9 | */ 10 | export const usePopup = (popupCreator: () => Omit, deps: any[]) => { 11 | const popupController = useContext(PopupControllerContext); 12 | 13 | /** 14 | * New id will be generated for every new popup, which gets new deps 15 | */ 16 | const popupId = useMemo(() => { 17 | return v4(); 18 | // eslint-disable-next-line react-hooks/exhaustive-deps 19 | }, [...deps]); 20 | 21 | const showPopup = useCallback(() => { 22 | const popupParams = popupCreator(); 23 | 24 | popupController.addPopup({ 25 | ...popupParams, 26 | id: popupId, 27 | }); 28 | 29 | return popupId; 30 | // The rule is disabled, cause of popupCreator 31 | // eslint-disable-next-line react-hooks/exhaustive-deps 32 | }, [popupController, popupId]); 33 | 34 | return { 35 | showPopup, 36 | }; 37 | }; 38 | 39 | /** 40 | * A hook to work with any popup 41 | */ 42 | export const usePopupActions = () => { 43 | const popupController = useContext(PopupControllerContext); 44 | 45 | return { 46 | closeAllPopups: popupController.removeAll, 47 | closePopupById: popupController.removePopupById, 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /src/application/shared/kit/popup/popup/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | import { colors, midTransitionDuration } from 'application/shared/styles/shared'; 4 | 5 | export const styles = createStyles({ 6 | root: { 7 | position: 'relative', 8 | 9 | _has_popup: { 10 | width: '100%', 11 | height: '100%', 12 | }, 13 | }, 14 | 15 | overlay: { 16 | position: 'absolute', 17 | top: 0, 18 | right: 0, 19 | bottom: 0, 20 | left: 0, 21 | transition: `${midTransitionDuration}ms background`, 22 | background: 'transparent', 23 | 24 | _has_popup: { 25 | background: colors.black.base(0.3), 26 | }, 27 | }, 28 | 29 | popup: { 30 | position: 'absolute', 31 | top: '50%', 32 | left: '50%', 33 | transform: 'translateX(-50%) translateY(-50%)', 34 | overflow: 'auto', 35 | maxHeight: '100%', 36 | maxWidth: '100%', 37 | 38 | // 95% to show users, that current element is just a popup, not a separate page 39 | _mobile: { 40 | width: '95%', 41 | height: '95%', 42 | }, 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /src/application/shared/kit/popup/types.ts: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'react'; 2 | 3 | export type Popup = { 4 | id: string; 5 | body: (params: { closePopup: () => void; popupId: string }) => JSX.Element; 6 | onClose?: () => void; 7 | options?: { 8 | closeOnEscape?: boolean; 9 | closeOnOverlayClick?: boolean; 10 | minWidth?: string; 11 | maxWidth?: string; 12 | minHeight?: string; 13 | maxHeight?: string; 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/application/shared/kit/preloader/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | export const Preloader = memo<{ purpose?: string }>(({ purpose }) => { 4 | return
Loading ⏱ {purpose && `for ${purpose}`}
; 5 | }); 6 | -------------------------------------------------------------------------------- /src/application/shared/kit/spoiler/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, memo } from 'react'; 2 | 3 | import { DATA_T_ATTRIBUTE_NAME } from 'framework/public/universal'; 4 | 5 | type Props = { 6 | initiallyExpanded?: boolean; 7 | onToggle?: (isExpanded: boolean) => void; 8 | children: (isExpanded: boolean, toggle: () => void) => React.ReactNode; 9 | [DATA_T_ATTRIBUTE_NAME]?: string; 10 | }; 11 | export const Spoiler = memo((props) => { 12 | const [isExpanded, setIsExpanded] = useState(!!props.initiallyExpanded); 13 | const onToggle = props.onToggle; 14 | const toggle = useCallback(() => { 15 | if (onToggle) { 16 | onToggle(!isExpanded); 17 | } 18 | setIsExpanded(!isExpanded); 19 | }, [onToggle, isExpanded]); 20 | 21 | const dtValue = props[DATA_T_ATTRIBUTE_NAME]; 22 | 23 | return
{props.children(isExpanded, toggle)}
; 24 | }); 25 | -------------------------------------------------------------------------------- /src/application/shared/kit/toast/index.tsx: -------------------------------------------------------------------------------- 1 | export { ToastControllerContext } from './infrastructure/context'; 2 | export { ToastController } from './infrastructure/controller'; 3 | 4 | export { Toasts } from './toasts'; 5 | 6 | export { useToast } from './infrastructure/hook'; 7 | -------------------------------------------------------------------------------- /src/application/shared/kit/toast/infrastructure/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import { ToastController } from './controller'; 4 | 5 | export const ToastControllerContext = createContext({} as ToastController); 6 | -------------------------------------------------------------------------------- /src/application/shared/kit/toast/infrastructure/controller.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | 3 | import { Toast } from '../types'; 4 | 5 | type Subscriber = (toast: Toast) => void; 6 | 7 | export class ToastController { 8 | private mutableSubscribers: Subscriber[] = []; 9 | 10 | public addToast = (toast: Omit) => { 11 | this.mutableSubscribers.forEach((subscriber) => { 12 | subscriber({ 13 | ...toast, 14 | id: v4(), 15 | }); 16 | }); 17 | }; 18 | 19 | public subscribeToAdd = (subscriber: Subscriber) => { 20 | this.mutableSubscribers.push(subscriber); 21 | 22 | const subscriberIndex = this.mutableSubscribers.length - 1; 23 | 24 | return () => { 25 | this.mutableSubscribers.splice(subscriberIndex, 1); 26 | }; 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/application/shared/kit/toast/infrastructure/hook.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { ToastControllerContext } from './context'; 4 | 5 | export const useToast = () => { 6 | const toastController = useContext(ToastControllerContext); 7 | 8 | return { 9 | showToast: toastController.addToast, 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/application/shared/kit/toast/toast/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | import { colors, midTransitionDuration } from 'application/shared/styles/shared'; 4 | 5 | export const styles = createStyles({ 6 | item: { 7 | position: 'relaitve', 8 | width: '100%', 9 | background: colors.white.base(), 10 | border: `2px solid ${colors.violet.base()}`, 11 | borderRadius: '2px', 12 | transition: `opacity ease ${midTransitionDuration}ms`, 13 | pointerEvents: 'auto', 14 | }, 15 | 16 | close: { 17 | position: 'absolute', 18 | top: 4, 19 | right: 4, 20 | padding: 2, 21 | fontSize: '22px', 22 | lineHeight: '22px', 23 | cursor: 'pointer', 24 | background: 'radial-gradient(ellipse at center, rgba(255,255,255,1) 80%,rgba(255,255,255,0) 100%)', 25 | pointerEvents: 'auto', 26 | }, 27 | 28 | body: { 29 | padding: 4, 30 | }, 31 | 32 | bar: { 33 | height: '4px', 34 | background: colors.orange.base(), 35 | width: '100%', 36 | transitionProperty: 'width', 37 | transitionTimingFunction: 'linear', 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/application/shared/kit/toast/toasts/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | export const styles = createStyles({ 4 | item: { 5 | position: 'absolute', 6 | transition: 'transform ease 300ms', 7 | width: '100%', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/application/shared/kit/toast/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Toast = { 4 | id: string; 5 | body: (p: { hideToast: () => void }) => React.ReactNode; 6 | options?: { 7 | hideOnClick?: boolean; 8 | freezeOnHover?: boolean; 9 | freezeOnVisibilitychange?: boolean; 10 | toastLiveTime?: number; 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/application/shared/kit/zIndex/index.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | export const styles = createStyles({ 4 | top: { 5 | position: 'absolute', 6 | top: 0, 7 | right: 0, 8 | bottom: 0, 9 | left: 0, 10 | zIndex: 2, 11 | height: '100%', 12 | pointerEvents: 'none', 13 | }, 14 | 15 | middle: { 16 | position: 'absolute', 17 | top: 0, 18 | right: 0, 19 | bottom: 0, 20 | left: 0, 21 | zIndex: 1, 22 | height: '100%', 23 | pointerEvents: 'none', 24 | }, 25 | 26 | base: { 27 | position: 'relative', 28 | height: '100%', 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /src/application/shared/kit/zIndex/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, type JSX } from 'react'; 2 | 3 | import { useStyles } from 'framework/public/styles'; 4 | 5 | import { styles } from './index.css'; 6 | 7 | interface ZIndexLayoutProps { 8 | base: JSX.Element; 9 | middle?: JSX.Element; 10 | top?: JSX.Element; 11 | } 12 | export const ZIndexLayout = memo((props: ZIndexLayoutProps) => { 13 | const css = useStyles(styles); 14 | 15 | return ( 16 | <> 17 |
{props.base}
18 | {props.middle ?
{props.middle}
: <>} 19 | {props.top ?
{props.top}
: <>} 20 | 21 | ); 22 | }); 23 | 24 | ZIndexLayout.displayName = 'ZIndexLayout'; 25 | -------------------------------------------------------------------------------- /src/application/shared/lib/api/createApi.ts: -------------------------------------------------------------------------------- 1 | import { Api, ApiContext } from './types'; 2 | 3 | /** 4 | * Creates a function which is easy to use with useApi 5 | */ 6 | export const createApi = (handler: Api) => { 7 | return (params: Params, ctx: ApiContext) => handler(params, ctx); 8 | }; 9 | -------------------------------------------------------------------------------- /src/application/shared/lib/api/index.ts: -------------------------------------------------------------------------------- 1 | export { createApi } from './createApi'; 2 | 3 | export { useApi } from './useApi'; 4 | -------------------------------------------------------------------------------- /src/application/shared/lib/api/types.ts: -------------------------------------------------------------------------------- 1 | import { Requester, AppLogger } from 'framework/public/types'; 2 | 3 | import { ApplicationConfig } from 'application/shared/config/types'; 4 | 5 | export type Api = (params: Params, ctx: ApiContext) => Promise; 6 | 7 | /** 8 | * All useful things for any API 9 | */ 10 | export type ApiContext = { 11 | config: ApplicationConfig; 12 | logger: AppLogger; 13 | request: Requester; 14 | }; 15 | -------------------------------------------------------------------------------- /src/application/shared/lib/api/useApi.ts: -------------------------------------------------------------------------------- 1 | import { Api } from './types'; 2 | import { useApiContext } from './useApiContext'; 3 | 4 | /** 5 | * Creates a ready to use API-function 6 | * 7 | * @example 8 | * 9 | * // In an useQuery 10 | * 11 | * const useGetItem = () => { 12 | * const getItem = useApi(getItemApi); 13 | * 14 | * return useAppQuery(['items'], () => getItem()); 15 | * } 16 | * 17 | * // Or in a mutation 18 | * 19 | * const useDeleteItem = () => { 20 | * const deleteItem = useApi(deleteItemApi); 21 | * 22 | * return useMutation((params: { id: string; }) => deleteItem(params); 23 | * }; 24 | * 25 | * // Or even in a component, if you need 26 | */ 27 | export const useApi = (api: Api) => { 28 | const ctx = useApiContext(); 29 | 30 | return (params: Params) => api(params, ctx); 31 | }; 32 | -------------------------------------------------------------------------------- /src/application/shared/lib/api/useApiContext.ts: -------------------------------------------------------------------------------- 1 | import { useAppLogger } from 'framework/public/universal'; 2 | 3 | import { useConfig } from 'application/shared/config/hook'; 4 | 5 | import { useRequest } from '../request'; 6 | 7 | /** 8 | * All useful things for any API 9 | */ 10 | export const useApiContext = () => { 11 | const config = useConfig(); 12 | const logger = useAppLogger(); 13 | const request = useRequest(); 14 | 15 | return { 16 | config, 17 | logger, 18 | request, 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/application/shared/lib/query/index.tsx: -------------------------------------------------------------------------------- 1 | export type { UnwrapQueryData } from './types'; 2 | 3 | export { ReactQueryBoundary } from './reactQueryBoundary'; 4 | -------------------------------------------------------------------------------- /src/application/shared/lib/query/reactQueryBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { QueryErrorResetBoundary } from '@tanstack/react-query'; 2 | import { Preloader } from 'application/shared/kit/preloader'; 3 | import { ComponentType, PropsWithChildren, ReactNode, Suspense, memo, useState } from 'react'; 4 | import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; 5 | 6 | type Props = { 7 | loadingFallback?: ReactNode; 8 | errorFallback?: ComponentType; 9 | }; 10 | /** 11 | * Just Tring to handle loading/error state in a React-way 12 | * WIP 13 | */ 14 | export const ReactQueryBoundary = memo>( 15 | ({ children, loadingFallback, errorFallback }) => { 16 | return ( 17 | 18 | {({ reset }) => ( 19 | 20 | }>{children} 21 | 22 | )} 23 | 24 | ); 25 | }, 26 | ); 27 | ReactQueryBoundary.displayName = 'ReactQueryBoundary'; 28 | 29 | const DefaultLoading = memo(() => { 30 | const [n, setN] = useState(0); 31 | return ( 32 |
33 |
Interactive preloader! n is {n}
34 | 35 | 36 |
37 | ); 38 | }); 39 | DefaultLoading.displayName = 'DefaultLoading'; 40 | 41 | const DefaultError = memo(({ resetErrorBoundary }) => { 42 | return ( 43 |
44 | 47 |
48 | ); 49 | }); 50 | DefaultError.displayName = 'DefaultError'; 51 | -------------------------------------------------------------------------------- /src/application/shared/lib/query/types.ts: -------------------------------------------------------------------------------- 1 | import { UseSuspenseQueryResult } from '@tanstack/react-query'; 2 | 3 | /** 4 | * Returns TData of the passed query or queryCreator function 5 | */ 6 | export type UnwrapQueryData< 7 | UseQueryCreatorOrUseQuery extends 8 | | ((params: any) => UseSuspenseQueryResult) 9 | | UseSuspenseQueryResult, 10 | > = UseQueryCreatorOrUseQuery extends (params: any) => UseSuspenseQueryResult 11 | ? TData 12 | : UseQueryCreatorOrUseQuery extends UseSuspenseQueryResult 13 | ? TData 14 | : never; 15 | 16 | /** 17 | * Returns TResult of any fetcher for any query 18 | */ 19 | export type FetcherResult (args: any) => Promise> = Awaited< 20 | ReturnType> 21 | >; 22 | -------------------------------------------------------------------------------- /src/application/shared/lib/request/index.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | import { Requester } from 'framework/public/types'; 4 | import { createRequest } from 'framework/public/universal'; 5 | 6 | export const RequesterContext = createContext( 7 | createRequest({ 8 | // This is just a reasonable default 9 | networkTimeout: 10000, 10 | }), 11 | ); 12 | 13 | export const useRequest = () => { 14 | const request = useContext(RequesterContext); 15 | 16 | return request; 17 | }; 18 | -------------------------------------------------------------------------------- /src/application/shared/lib/routing/index.ts: -------------------------------------------------------------------------------- 1 | export { parsePageQueryParam } from './parsePageQueryParam'; 2 | -------------------------------------------------------------------------------- /src/application/shared/lib/routing/parsePageQueryParam.ts: -------------------------------------------------------------------------------- 1 | import type { URLQueryParams } from 'framework/public/types'; 2 | 3 | /** 4 | * queryParams can have string or string array as values 5 | * So, we need to parse a page number, to prevent of passing anything 6 | * which is not a corrent positive number 7 | */ 8 | export function parsePageQueryParam(queryParams: URLQueryParams): number { 9 | const rawPageQueryParam = queryParams['p']; 10 | 11 | if (!rawPageQueryParam || !rawPageQueryParam.length || !rawPageQueryParam[0]) { 12 | return 1; 13 | } 14 | 15 | const parsedPage = parseInt(rawPageQueryParam[0], 10); 16 | 17 | return Number.isNaN(parsedPage) ? 1 : parsedPage <= 0 ? 1 : parsedPage; 18 | } 19 | -------------------------------------------------------------------------------- /src/application/shared/lib/showPageName/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This util is to test build with dynamic imports of non React code 3 | */ 4 | export const showPageName = (pageName: string) => { 5 | console.log('Test util to show a pageName:', pageName); 6 | }; 7 | -------------------------------------------------------------------------------- /src/application/shared/styles/functions.ts: -------------------------------------------------------------------------------- 1 | export function lineClamped(linesCount: number, lineHeight: number): React.CSSProperties { 2 | return { 3 | display: '-webkit-box', 4 | lineHeight: `${lineHeight}px`, 5 | WebkitBoxOrient: 'vertical', 6 | WebkitLineClamp: linesCount, 7 | overflow: 'hidden', 8 | textOverflow: 'ellipsis', 9 | maxHeight: `${linesCount * lineHeight}px`, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/application/shared/styles/global.css.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from 'framework/public/styles'; 2 | 3 | import { keyframeNames } from './shared'; 4 | 5 | export const styles = createStyles({ 6 | ':global': { 7 | [`@keyframes ${keyframeNames.opacity}`]: { 8 | from: { 9 | opacity: 0, 10 | }, 11 | 12 | to: { 13 | opacity: 1, 14 | }, 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/framework/applications/client/store/index.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | import { PlatformAPI } from 'framework/infrastructure/platform'; 4 | import { configureStore } from 'framework/infrastructure/router/redux/store/configureStore'; 5 | import { CreateReducerOptions } from 'framework/infrastructure/router/redux/store/reducer'; 6 | import { AnyAppContext, AnyPage } from 'framework/infrastructure/router/types'; 7 | 8 | import { createTitleMiddleware } from './middleware/title'; 9 | import { startup } from './startup'; 10 | import { GetTitle } from '../types'; 11 | import { addStoreSubscribers } from '../utils/addStoreSubscribers'; 12 | 13 | type Params> = { 14 | compileAppURL: (appContext: AnyAppContext) => string; 15 | createReducerOptions: CreateReducerOptions; 16 | queryClient: QueryClient; 17 | windowApi: PlatformAPI['window']; 18 | getTitle: GetTitle; 19 | }; 20 | export function restoreStore>({ 21 | compileAppURL, 22 | createReducerOptions, 23 | queryClient, 24 | windowApi, 25 | getTitle, 26 | }: Params) { 27 | const initialState = window.__initialRouterState; 28 | const mutableEnhancers = []; 29 | 30 | if (process.env.NODE_ENV === 'development') { 31 | if (typeof window.__REDUX_DEVTOOLS_EXTENSION__ !== 'undefined') { 32 | mutableEnhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__()); 33 | } 34 | } 35 | 36 | const store = configureStore({ 37 | initialState, 38 | middlewares: [ 39 | createTitleMiddleware({ 40 | queryClient, 41 | windowApi, 42 | getTitle, 43 | }), 44 | ], 45 | enhancers: mutableEnhancers, 46 | compileAppURL, 47 | createReducerOptions, 48 | }); 49 | 50 | addStoreSubscribers(store); 51 | 52 | return Promise.resolve(store.dispatch(startup())).then(() => store); 53 | } 54 | -------------------------------------------------------------------------------- /src/framework/applications/client/store/middleware/title.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | import { Middleware } from 'redux'; 3 | 4 | import { PlatformAPI } from 'framework/infrastructure/platform'; 5 | import { AnyAppState, AnyPage } from 'framework/infrastructure/router/types'; 6 | 7 | import { GetTitle } from '../../types'; 8 | 9 | type Params> = { 10 | queryClient: QueryClient; 11 | windowApi: PlatformAPI['window']; 12 | getTitle: GetTitle; 13 | }; 14 | export function createTitleMiddleware>({ 15 | queryClient, 16 | windowApi, 17 | getTitle, 18 | }: Params): Middleware, AnyAppState> { 19 | let mutableLastTitle = ''; 20 | 21 | return (store) => (next) => (action) => { 22 | next(action); 23 | 24 | const newTitle = getTitle({ queryClient, ...(store.getState().appContext as any) }); 25 | 26 | if (mutableLastTitle !== newTitle) { 27 | mutableLastTitle = newTitle; 28 | windowApi.setTitle(newTitle); 29 | } 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/framework/applications/client/store/startup.ts: -------------------------------------------------------------------------------- 1 | import { historyReplace } from 'framework/infrastructure/router/redux/actions/router'; 2 | import { createSignal } from 'framework/infrastructure/signal'; 3 | 4 | /** 5 | * Signal, which will be dispatched before hydration 6 | * Caution, do not add any long tasks here! 7 | */ 8 | export const startup = createSignal('clientStartup', () => historyReplace()); 9 | -------------------------------------------------------------------------------- /src/framework/applications/client/types.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | import { AnyPage, URLQueryParams } from 'framework/public/types'; 4 | 5 | export type GetTitle> = (params: { 6 | queryClient: QueryClient; 7 | page: Page; 8 | URLQueryParams: URLQueryParams; 9 | }) => string; 10 | -------------------------------------------------------------------------------- /src/framework/applications/client/utils/addStoreSubscribers.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'redux'; 2 | 3 | import { replaceState } from 'framework/infrastructure/router/redux/actions/router'; 4 | import { onHistoryMove } from 'framework/infrastructure/router/redux/middlewares/historyActons'; 5 | import { AnyAppState } from 'framework/infrastructure/router/types'; 6 | 7 | export function addStoreSubscribers(store: Store) { 8 | onHistoryMove((curAppContext) => { 9 | store.dispatch(replaceState(curAppContext)); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/framework/applications/client/utils/afterAppRendered.ts: -------------------------------------------------------------------------------- 1 | import { BaseApplicationConfig } from 'framework/config/types'; 2 | import { loadAllStylesOnClient } from 'framework/infrastructure/css/loadAllStylesOnClient'; 3 | import { AppLogger } from 'framework/infrastructure/logger'; 4 | import { getFullPathForStaticResource } from 'framework/infrastructure/webpack/getFullPathForStaticResource'; 5 | 6 | type Params = { 7 | config: BaseApplicationConfig; 8 | logger: AppLogger; 9 | }; 10 | export const afterAppRendered = ({ config, logger }: Params) => { 11 | loadAllStylesOnClient({ 12 | fileName: getFullPathForStaticResource({ 13 | chunkName: 'stylesLtr', 14 | staticResourcesPathMapping: window.__staticResourcesPathMapping.pathMapping, 15 | publicPath: config.publicPath, 16 | resourceType: 'css', 17 | }), 18 | }); 19 | 20 | console.log('afterAppRendered callback executed'); 21 | 22 | logger.sendInfoLog({ 23 | id: 'startup-log', 24 | message: `afterAppRendered callback executed`, 25 | data: { 26 | initialURL: window.location.href, 27 | }, 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /src/framework/applications/client/utils/createClientSessionObject.ts: -------------------------------------------------------------------------------- 1 | import { defaultSession } from 'framework/infrastructure/session/context'; 2 | import { Session } from 'framework/infrastructure/session/types'; 3 | 4 | export const createClientSessionObject = (): Session => { 5 | return { 6 | ...defaultSession, 7 | ...window.__session, 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/framework/applications/server/logs/logApplicationBootstrapError.ts: -------------------------------------------------------------------------------- 1 | import { AppLogger } from 'framework/infrastructure/logger'; 2 | import { getMessageAndStackParamsFromError } from 'framework/infrastructure/logger/utils'; 3 | 4 | import { devConsoleLog } from 'lib/console/devConsole'; 5 | 6 | type Params = { 7 | error: Error; 8 | appLogger: AppLogger; 9 | sourceName: 'getMetaData' | 'onShellError' | 'onError' | 'InfrastructurePromisesError'; 10 | }; 11 | export const logApplicationBootstrapError = ({ error, appLogger, sourceName }: Params) => { 12 | const { message, stack } = getMessageAndStackParamsFromError(error, { 13 | defaultMessage: `${sourceName} default error message`, 14 | }); 15 | 16 | devConsoleLog(message, error); 17 | 18 | let id = 'qqqqqq'; 19 | 20 | switch (sourceName) { 21 | case 'InfrastructurePromisesError': 22 | id = 'cv6gga'; 23 | break; 24 | case 'getMetaData': 25 | id = 'vg555a'; 26 | break; 27 | case 'onError': 28 | id = '7uu2dc'; 29 | break; 30 | case 'onShellError': 31 | id = 'bn2f899'; 32 | break; 33 | } 34 | 35 | appLogger.sendErrorLog({ 36 | id, 37 | source: 'application_bootstrap', 38 | message, 39 | stack, 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /src/framework/applications/server/logs/logServerUncaughtException.ts: -------------------------------------------------------------------------------- 1 | import { logger } from 'framework/infrastructure/logger/init'; 2 | import { addAppVersion, getMessageAndStackParamsFromError } from 'framework/infrastructure/logger/utils'; 3 | 4 | import { devConsoleLog } from 'lib/console/devConsole'; 5 | 6 | export function logServerUncaughtException(error: Error) { 7 | devConsoleLog('logServerUncaughtException error: ', error); 8 | 9 | const { message, stack } = getMessageAndStackParamsFromError(error); 10 | 11 | logger.error({ 12 | level: 'error', 13 | message: 'logServerUncaughtException default error message', 14 | environment: 'server', 15 | id: 'cy6ek', 16 | 'error.type': 'uncaughtException', 17 | 'error.message': message, 18 | 'error.stack': stack, 19 | ...addAppVersion(), 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/framework/applications/server/logs/logServerUnhandledRejection.ts: -------------------------------------------------------------------------------- 1 | import { logger } from 'framework/infrastructure/logger/init'; 2 | import { addAppVersion, getMessageAndStackParamsFromError } from 'framework/infrastructure/logger/utils'; 3 | 4 | import { devConsoleLog } from 'lib/console/devConsole'; 5 | 6 | export function logServerUnhandledRejection(error?: Error) { 7 | devConsoleLog('logServerUnhandledRejection error: ', error); 8 | 9 | if (!error) { 10 | logger.error({ 11 | level: 'error', 12 | environment: 'server', 13 | id: 'k6b2w', 14 | message: 'logServerUnhandledRejection default error message', 15 | 'error.type': 'unhandledRejection', 16 | 'error.message': 'No reason in UnhandledRejection', 17 | 'error.stack': '', 18 | ...addAppVersion(), 19 | }); 20 | return; 21 | } 22 | 23 | const { message, stack } = getMessageAndStackParamsFromError(error); 24 | 25 | logger.error({ 26 | level: 'error', 27 | environment: 'server', 28 | id: '3c67e', 29 | message: 'logServerUnhandledRejection default error message', 30 | 'error.type': 'unhandledRejection', 31 | 'error.message': message, 32 | 'error.stack': stack, 33 | ...addAppVersion(), 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/framework/applications/server/middlewares/clientIP.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | export function clientIp(mutableReq: Request, _res: Response, next: NextFunction) { 4 | mutableReq.clientIp = 5 | mutableReq.get('x-forwarded-for') || 6 | mutableReq.get('x-real-ip') || 7 | mutableReq.socket.remoteAddress || 8 | mutableReq.ip; 9 | return next(); 10 | } 11 | -------------------------------------------------------------------------------- /src/framework/applications/server/middlewares/routerErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import { logger } from 'framework/infrastructure/logger/init'; 4 | import { addAppVersion, getMessageAndStackParamsFromError } from 'framework/infrastructure/logger/utils'; 5 | 6 | type Params = { 7 | onErrorFallbackHTML: (error?: Error) => string; 8 | }; 9 | export function createRouterErrorHandlerMiddleware({ 10 | onErrorFallbackHTML, 11 | }: Params): express.ErrorRequestHandler { 12 | return (error, _req, res, _next) => { 13 | const { message, stack } = getMessageAndStackParamsFromError(error, { 14 | defaultMessage: 'Express router error', 15 | stackSize: 1024, 16 | }); 17 | 18 | logger.error({ 19 | level: 'error', 20 | environment: 'server', 21 | id: 'vzowo', 22 | 'error.type': 'router', 23 | 'error.message': message, 24 | 'error.stack': stack, 25 | ...addAppVersion(), 26 | }); 27 | 28 | res.status(500).send(onErrorFallbackHTML(error)); 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/framework/applications/server/routes/utility.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { handleClientLogPath } from 'framework/applications/shared/logger'; 4 | import { frameworkCookies } from 'framework/constants/cookies'; 5 | import { handleLogFromClient } from 'framework/infrastructure/logger/serverLog'; 6 | 7 | export const utilityRouter = Router(); 8 | 9 | utilityRouter.get('/healthcheck', (_req, res) => res.status(200).send('OK')); 10 | 11 | utilityRouter.post(handleClientLogPath, (req, res) => { 12 | handleLogFromClient(req, { 13 | sidCookieName: frameworkCookies.sid.name, 14 | userCookieName: frameworkCookies.user.name, 15 | }); 16 | res.json({ status: 'ok' }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/framework/applications/server/store/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { AnyAction, Middleware } from 'redux'; 3 | 4 | import { configureStore } from 'framework/infrastructure/router/redux/store/configureStore'; 5 | import { CreateReducerOptions } from 'framework/infrastructure/router/redux/store/reducer'; 6 | import { AnyAppContext, AnyPage } from 'framework/infrastructure/router/types'; 7 | 8 | import { startup } from './startup'; 9 | import { logger } from '../utils/reduxLogger'; 10 | 11 | type Params = { 12 | req: Request; 13 | res: Response; 14 | parseURL: (URL: string) => AnyAction[]; 15 | compileAppURL: (appContext: AnyAppContext) => string; 16 | initialAppContext: AnyAppContext; 17 | createReducerOptions: CreateReducerOptions; 18 | }; 19 | export async function restoreStore>({ 20 | parseURL, 21 | compileAppURL, 22 | req, 23 | initialAppContext, 24 | createReducerOptions, 25 | }: Params) { 26 | const middlewares: Middleware[] = process.env.NODE_ENV !== 'production' ? [logger] : []; 27 | const routerActions = parseURL(req.url); 28 | const store = configureStore({ 29 | initialState: { 30 | appContext: initialAppContext, 31 | }, 32 | middlewares, 33 | enhancers: [], 34 | compileAppURL, 35 | createReducerOptions, 36 | }); 37 | 38 | return Promise.resolve(store.dispatch(startup({ routerActions }))).then(() => store); 39 | } 40 | -------------------------------------------------------------------------------- /src/framework/applications/server/store/startup.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | 3 | import { createSignal, parallel } from 'framework/infrastructure/signal'; 4 | 5 | /** 6 | * Signal, which will be dispatched before hydration 7 | * Caution, do not add any long tasks here! 8 | */ 9 | export const startup = createSignal('serverStartup', (params: { routerActions: Action[] }) => 10 | parallel(...params.routerActions), 11 | ); 12 | -------------------------------------------------------------------------------- /src/framework/applications/server/types.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | import { AnyPage, URLQueryParams } from 'framework/public/types'; 4 | import { Metadata } from 'framework/types/metadata'; 5 | 6 | export type GetMetadata> = (params: { 7 | queryClient: QueryClient; 8 | page: Page; 9 | URLQueryParams: URLQueryParams; 10 | }) => Promise; 11 | -------------------------------------------------------------------------------- /src/framework/applications/server/utils/assets.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import { 4 | PAGE_DEPENDENCIES_FILE_NAME, 5 | ASSETS_STATS_FILE_NAME, 6 | } from 'framework/infrastructure/webpack/constants'; 7 | 8 | export interface AssetsList { 9 | [chunkName: string]: string[]; 10 | } 11 | 12 | interface StatsData { 13 | assetsByChunkName: AssetsList; 14 | } 15 | export interface AssetsData { 16 | pathMapping: AssetsList; 17 | inlineContent: string; 18 | } 19 | 20 | /** 21 | * Reads stats.json with all stats about current client build 22 | * Reads webpack runtime chunk 23 | */ 24 | export async function readAssetsInfo(): Promise { 25 | try { 26 | const statsContent = await readFileContent(`${process.cwd()}/build/${ASSETS_STATS_FILE_NAME}`); 27 | const pathMapping = JSON.parse(statsContent).assetsByChunkName as StatsData['assetsByChunkName']; 28 | const webpackRuntimeCode = await readFileContent( 29 | `${process.cwd()}/build/public/${pathMapping['runtime']}`, 30 | ); 31 | 32 | return { 33 | pathMapping, 34 | inlineContent: `${webpackRuntimeCode}`, 35 | }; 36 | } catch (error) { 37 | console.error(error); 38 | throw new Error('stats.json or webpack runtime file are not ready'); 39 | } 40 | } 41 | 42 | /** 43 | * Reads a file with all deps for each page 44 | */ 45 | export function readPageDependenciesStats(): Promise<{ [pageChunkName: string]: string[] }> { 46 | return readFileContent(`${process.cwd()}/build/${PAGE_DEPENDENCIES_FILE_NAME}`).then((content) => { 47 | return JSON.parse(content); 48 | }); 49 | } 50 | 51 | function readFileContent(filePath: string) { 52 | return new Promise((resolve, reject) => { 53 | fs.readFile(filePath, 'utf8', (err, content: string) => { 54 | if (err) { 55 | reject(err); 56 | } else { 57 | resolve(content); 58 | } 59 | }); 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /src/framework/applications/server/utils/createOnFinishHadler.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | import { Response } from 'express'; 3 | 4 | type OnFinishHadlerCreatorParams = { 5 | res: Response; 6 | renderTimeoutId: NodeJS.Timeout | undefined; 7 | queryClient: QueryClient; 8 | }; 9 | export const createOnFinishHadler = ({ 10 | res, 11 | renderTimeoutId, 12 | queryClient, 13 | }: OnFinishHadlerCreatorParams) => { 14 | return () => { 15 | if (renderTimeoutId) { 16 | clearTimeout(renderTimeoutId); 17 | } 18 | 19 | /** 20 | * In case you are creating the QueryClient for every request, 21 | * React Query creates the isolated cache for this client, 22 | * which is preserved in memory for the cacheTime period. 23 | * That may lead to high memory consumption on server 24 | * in case of high number of requests during that period. 25 | * 26 | * On the server, cacheTime defaults to Infinity 27 | * which disables manual garbage collection and will automatically clear 28 | * memory once a request has finished. 29 | * If you are explicitly setting a non-Infinity cacheTime 30 | * then you will be responsible for clearing the cache early. 31 | * https://tanstack.com/query/v4/docs/guides/ssr#high-memory-consumption-on-server 32 | */ 33 | queryClient.clear(); 34 | 35 | /** 36 | * Actually, it is not necessary to call res.end manually, 37 | * cause React does this by itself 38 | * 39 | * But, if we have any wrapper on res, we can not be sure, 40 | * that wrapper implements all needed methods (especially _final) 41 | * So, the `end` method will be called manually, if writable has not been ended yet. 42 | */ 43 | if (!res.writableEnded) { 44 | res.end(); 45 | } 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/framework/applications/server/utils/createServerSessionObject.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { v4 } from 'uuid'; 3 | 4 | import { frameworkCookies } from 'framework/constants/cookies'; 5 | import { defaultSession } from 'framework/infrastructure/session/context'; 6 | import { Session } from 'framework/infrastructure/session/types'; 7 | 8 | import { createCookieAPI } from 'lib/cookies/server'; 9 | 10 | export const createServerSessionObject = (req: Request, res: Response): Session => { 11 | const userAgentHeader = req.headers['user-agent']; 12 | const userAgent = 13 | userAgentHeader && Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader || ''; 14 | const searchBotData = req.searchBotData; 15 | const cookiesAPI = createCookieAPI(req, res); 16 | 17 | let sid = cookiesAPI.get(frameworkCookies.sid.name) || ''; 18 | 19 | if (!sid) { 20 | sid = v4(); 21 | cookiesAPI.set(frameworkCookies.sid.name, sid, frameworkCookies.sid.options); 22 | } 23 | 24 | let user = cookiesAPI.get(frameworkCookies.user.name) || ''; 25 | 26 | if (!user) { 27 | user = v4(); 28 | cookiesAPI.set(frameworkCookies.user.name, user, frameworkCookies.user.options); 29 | } 30 | 31 | const sessionData: Session = { 32 | ...defaultSession, 33 | user, 34 | sid, 35 | ip: req.clientIp, 36 | userAgent, 37 | isIOS: req.parsedUA.isiPad || req.parsedUA.isiPhone || req.parsedUA.isiPod, 38 | isAndroid: req.parsedUA.isAndroid, 39 | isMobile: req.parsedUA.isMobile, 40 | isTablet: req.parsedUA.isTablet, 41 | }; 42 | 43 | if (!searchBotData) { 44 | return sessionData; 45 | } 46 | 47 | return { 48 | ...sessionData, 49 | searchBotName: searchBotData.botName, 50 | isSearchBot: true, 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /src/framework/applications/server/utils/reduxLogger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { createLogger } from 'redux-logger'; 4 | 5 | export const logger = createLogger({ 6 | level: 'log', 7 | timestamp: true, 8 | duration: true, 9 | logger: { 10 | log(type: string, _style: string, content: any) { 11 | switch (type.trim()) { 12 | case '%c prev state': 13 | if (process.env.REDUX_LOG === 'full') { 14 | console.log(content); 15 | } 16 | break; 17 | case '%c action': 18 | console.log('------------------'); 19 | console.log(`DATE: [${new Date().toISOString()}] `); 20 | console.log('ACTION: ', content.type); 21 | console.log('PAYLOAD: ', content.payload); 22 | break; 23 | case '%c next state': 24 | if (process.env.REDUX_LOG === 'full') { 25 | console.log(content); 26 | } 27 | break; 28 | default: 29 | } 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/framework/applications/shared/logger.ts: -------------------------------------------------------------------------------- 1 | import { utilityRouterPath } from 'framework/constants/application'; 2 | import { createUniversalAppLoggerCreator } from 'framework/infrastructure/logger'; 3 | 4 | export const handleClientLogPath = '/log'; 5 | 6 | export const createAppLogger = createUniversalAppLoggerCreator( 7 | `${utilityRouterPath}${handleClientLogPath}`, 8 | ); 9 | -------------------------------------------------------------------------------- /src/framework/config/generator/client.ts: -------------------------------------------------------------------------------- 1 | import { isServer } from 'lib/browser'; 2 | 3 | import { APPLICATION_CONFIG_VAR_NAME } from './shared'; 4 | import { BaseApplicationConfig } from '../types'; 5 | 6 | export const getClientApplicationConfig = (): Config => { 7 | if (isServer) { 8 | throw new Error('Can not get client config on a server!'); 9 | } 10 | 11 | if (!(window as any)[APPLICATION_CONFIG_VAR_NAME]) { 12 | throw new Error('Config is not found in window!'); 13 | } 14 | 15 | return (window as any)[APPLICATION_CONFIG_VAR_NAME]; 16 | }; 17 | -------------------------------------------------------------------------------- /src/framework/config/generator/server.ts: -------------------------------------------------------------------------------- 1 | import { parseEnvParams } from 'framework/config/utils/parseEnvParams'; 2 | 3 | import { BaseApplicationConfig, BaseServerConfig } from '../types'; 4 | 5 | if (process.env.APP_ENV === 'client') { 6 | throw new Error('Config generator should not be existed on client!'); 7 | } 8 | 9 | const envParams = (process && process.env) || {}; 10 | 11 | /** 12 | * Build config for a server which serve an application 13 | */ 14 | const buildServerConfig = (serverConfig: Config): Config => { 15 | return { 16 | ...serverConfig, 17 | ...parseEnvParams(serverConfig, envParams, 'server'), 18 | }; 19 | }; 20 | 21 | /** 22 | * Build config for an application on server side 23 | */ 24 | const buildServerApplicationConfig = ( 25 | serverApplicationConfig: Config, 26 | ): Config => { 27 | return { 28 | ...serverApplicationConfig, 29 | ...parseEnvParams(serverApplicationConfig, envParams, 'app'), 30 | ...parseEnvParams(serverApplicationConfig, envParams, 'server_app'), 31 | }; 32 | }; 33 | 34 | /** 35 | * Build config for an application on client side 36 | */ 37 | const buildClientApplicationConfig = ( 38 | clientApplicationConfig: Config, 39 | ): Config => { 40 | return { 41 | ...clientApplicationConfig, 42 | ...parseEnvParams(clientApplicationConfig, envParams, 'app'), 43 | ...parseEnvParams(clientApplicationConfig, envParams, 'client_app'), 44 | }; 45 | }; 46 | 47 | export { buildClientApplicationConfig, buildServerApplicationConfig, buildServerConfig }; 48 | -------------------------------------------------------------------------------- /src/framework/config/generator/shared.ts: -------------------------------------------------------------------------------- 1 | export const APPLICATION_CONFIG_VAR_NAME = '__application_cfg'; 2 | -------------------------------------------------------------------------------- /src/framework/config/react/index.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | import { BaseApplicationConfig } from '../types'; 4 | 5 | export const ConfigContext = createContext({} as any); 6 | 7 | export const useAnyConfig = () => { 8 | const config = useContext(ConfigContext); 9 | 10 | return config as T; 11 | }; 12 | -------------------------------------------------------------------------------- /src/framework/config/types.ts: -------------------------------------------------------------------------------- 1 | export type AnyConfigValue = string | boolean | number; 2 | // @TODO AnyConfig as a parent for BaseConfig? 3 | export interface AnyConfig { 4 | [key: string]: AnyConfigValue; 5 | } 6 | 7 | export interface BaseServerConfig { 8 | /** 9 | * A port for an express server 10 | */ 11 | port: number; 12 | } 13 | 14 | export interface BaseApplicationConfig { 15 | publicPath: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/framework/constants/application.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Can be rewrited via env-var APPLICATION_CONTAINER_ID 3 | */ 4 | export const applicationContainerId = process.env.APPLICATION_CONTAINER_ID || '__application'; 5 | 6 | /** 7 | * Can be rewrited via env-var SERVER_UTILITY_ROUTER_PATH 8 | */ 9 | export const utilityRouterPath = process.env.SERVER_UTILITY_ROUTER_PATH || '/_'; 10 | -------------------------------------------------------------------------------- /src/framework/constants/cookies.ts: -------------------------------------------------------------------------------- 1 | import { CookieOptions } from 'express'; 2 | import { CookieAttributes } from 'js-cookie'; 3 | 4 | type FrameworkCookieName = 'user' | 'sid' | 'pushedResources'; 5 | export const frameworkCookies: FrameworkCookies = { 6 | // Unic uuid for every user. This uuid will be the same through many sessions 7 | user: { 8 | name: '_application_user', 9 | options: { 10 | path: '/', 11 | sameSite: 'strict', 12 | // 50 years 13 | maxAge: 1576800000, 14 | }, 15 | }, 16 | 17 | // Unic uuid for every new session 18 | sid: { 19 | name: '_application_sid', 20 | options: { 21 | path: '/', 22 | sameSite: 'strict', 23 | }, 24 | }, 25 | 26 | pushedResources: { 27 | name: 'pushed_resources', 28 | options: { 29 | sameSite: 'strict', 30 | // 31 days 31 | maxAge: 2678400, 32 | }, 33 | }, 34 | }; 35 | 36 | type FrameworkCookies = { 37 | [key in FrameworkCookieName]: { 38 | name: string; 39 | options: CookieOptions | CookieAttributes; 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/__tests__/serverStore.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { CSSServerProviderStore } from '../provider/serverStore'; 4 | 5 | describe('generator / generateCss', () => { 6 | it('Return hash and used mods in addStyles. Return style properites in hash key in style descriptor', () => { 7 | const store = new CSSServerProviderStore(); 8 | const style = { 9 | selector: { 10 | width: '300px', 11 | height: '200px', 12 | 13 | _mod: { 14 | color: 'red', 15 | }, 16 | }, 17 | }; 18 | const addStyleResult = store.addStyles('selector', style['selector'], ['_mod']); 19 | 20 | expect(addStyleResult).to.deep.eq({ 21 | hash: '_mc8cmb', 22 | usedModifiers: ['_mod'], 23 | }); 24 | expect(store.getStyles()).to.deep.eq({ 25 | _mc8cmb: { width: '300px', height: '200px', _mod: { color: 'red' } }, 26 | }); 27 | }); 28 | 29 | it('Return hash in addStyles. Return style properites in hash key in style descriptor', () => { 30 | const store = new CSSServerProviderStore(); 31 | const style = { 32 | selector: { 33 | width: '300px', 34 | height: '200px', 35 | 36 | ':hover': { 37 | color: 'red', 38 | }, 39 | }, 40 | }; 41 | const addStyleResult = store.addStyles('selector', style['selector']); 42 | 43 | expect(addStyleResult).to.deep.eq({ 44 | hash: '_zowm8f', 45 | usedModifiers: undefined, 46 | }); 47 | expect(store.getStyles()).to.deep.eq({ 48 | _zowm8f: { width: '300px', height: '200px', ':hover': { color: 'red' } }, 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { isValidStyleObject } from '../generator/utils'; 4 | 5 | describe('generator / utils/ isValidStyleObject', () => { 6 | it('Return true for valid css-object', () => { 7 | expect( 8 | isValidStyleObject({ 9 | hash: { 10 | color: 'red', 11 | }, 12 | }), 13 | ).be.eq(true); 14 | }); 15 | 16 | it('Return false for not valid css-object: empty object', () => { 17 | expect(isValidStyleObject({})).be.eq(false); 18 | }); 19 | 20 | it('Return false for not valid css-object: object without inner properities with objects', () => { 21 | expect( 22 | isValidStyleObject({ 23 | name: 'Name', 24 | }), 25 | ).be.eq(false); 26 | }); 27 | 28 | it('Return false for not valid css-object: object is not passed or passed undefined', () => { 29 | expect(isValidStyleObject(undefined)).be.eq(false); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/generator/index.ts: -------------------------------------------------------------------------------- 1 | import { cssify } from './utils'; 2 | import { Styles } from '../types'; 3 | 4 | export function generateCss( 5 | prefixedStyles: Styles, 6 | dir: 'ltr' | 'rtl' = 'ltr', 7 | ) { 8 | return Object.keys(prefixedStyles).reduce((res, hash) => { 9 | res += cssify((prefixedStyles as any)[hash], hash, dir); 10 | return res; 11 | }, ''); 12 | } 13 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/generator/prefixer.ts: -------------------------------------------------------------------------------- 1 | import { createPrefixer } from 'inline-style-prefixer'; 2 | 3 | const generateData = require('inline-style-prefixer/lib/generator'); 4 | const plugins = require('inline-style-prefixer/lib/plugins'); 5 | 6 | // npx browserslist "last 1 version, >1%" 7 | const browserList = { 8 | and_chr: 70, 9 | and_ff: 63, 10 | and_qq: 1.2, 11 | and_uc: 11.8, 12 | android: 67, 13 | baidu: 7.12, 14 | bb: 10, 15 | chrome: 58, 16 | edge: 17, 17 | firefox: 63, 18 | ie: 11, 19 | ie_mob: 11, 20 | ios_saf: '11.3-11.4', 21 | op_mini: 'all', 22 | op_mob: 46, 23 | opera: 57, 24 | safari: 12, 25 | samsung: 7.2, 26 | }; 27 | 28 | const prefixData = generateData.default(browserList); 29 | 30 | export const prefix = createPrefixer({ 31 | ...prefixData, 32 | // eslint-disable-next-line @typescript-eslint/ban-types 33 | plugins: plugins.default.filter((pluginFunction: Function) => { 34 | return prefixData.plugins.includes(pluginFunction.name); 35 | }), 36 | }); 37 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/hook.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'react'; 2 | 3 | import { CSSProviderContext } from './provider'; 4 | import { Styles } from './types'; 5 | 6 | /** 7 | * The hook, allows to use styles from `createStyles` 8 | * 9 | */ 10 | export const useStyles = >( 11 | styles: StyleDescriptor, 12 | ) => { 13 | const { css } = useContext(CSSProviderContext); 14 | 15 | return useMemo(() => css(styles), [css, styles]); 16 | }; 17 | 18 | export const createStyles = >( 19 | styles: StyleDescriptor, 20 | ): StyleDescriptor => styles; 21 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/loadAllStylesOnClient.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Insert a link to all styles into the body 3 | */ 4 | export function loadAllStylesOnClient(params: { fileName: string }): void { 5 | if (typeof window === 'undefined') { 6 | return; 7 | } 8 | 9 | const { fileName } = params; 10 | 11 | const linkEl = document.createElement('link'); 12 | const href = fileName.replace('//', '/'); 13 | 14 | linkEl.setAttribute('rel', 'stylesheet'); 15 | linkEl.setAttribute('href', href); 16 | 17 | 'requestIdleCallback' in window 18 | ? window.requestIdleCallback(() => { 19 | document.body.appendChild(linkEl); 20 | }) 21 | : setTimeout(() => { 22 | document.body.appendChild(linkEl); 23 | }, 0); 24 | } 25 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/provider/clientStore.ts: -------------------------------------------------------------------------------- 1 | import { CSSProviderStoreInterface } from './types'; 2 | import { Style } from '../types'; 3 | 4 | export class CSSClientProviderStore implements CSSProviderStoreInterface { 5 | public addStyles(_selector: string, style: Style, usedModifiers?: string[]) { 6 | return { 7 | hash: style.toString(), 8 | usedModifiers, 9 | }; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/provider/serverStore.ts: -------------------------------------------------------------------------------- 1 | import { CSSProviderStoreInterface } from './types'; 2 | import { murmurhash2 } from '../stringHash'; 3 | import { Style, Styles } from '../types'; 4 | 5 | export class CSSServerProviderStore implements CSSProviderStoreInterface { 6 | private _hasStyles = false; 7 | private _mutableProccesedStylesCache: { 8 | [hash: string]: boolean; 9 | } = {}; 10 | 11 | protected mutableProccesedStyles: Styles = {}; 12 | 13 | public generateHash(selector: string, style: Style): string { 14 | let hash = `_${murmurhash2(JSON.stringify(style))}`; 15 | 16 | if (process.env.NODE_ENV === 'development') { 17 | hash = `${selector}_${hash}`; 18 | } 19 | 20 | return hash; 21 | } 22 | 23 | public addStyles(selector: string, style: Style, usedModifiers?: string[]) { 24 | const hash = this.generateHash(selector, style); 25 | 26 | if (!this._mutableProccesedStylesCache[hash]) { 27 | this._mutableProccesedStylesCache[hash] = true; 28 | 29 | this.mutableProccesedStyles[hash] = style; 30 | 31 | this._hasStyles = true; 32 | } 33 | 34 | return { 35 | hash, 36 | usedModifiers, 37 | }; 38 | } 39 | 40 | public getStyles() { 41 | return this.mutableProccesedStyles; 42 | } 43 | 44 | public clearStore() { 45 | this._hasStyles = false; 46 | this.mutableProccesedStyles = {}; 47 | } 48 | 49 | public hasStyles() { 50 | return this._hasStyles; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/provider/types.ts: -------------------------------------------------------------------------------- 1 | import { Style } from '../types'; 2 | 3 | export interface CSSProviderStoreInterface { 4 | addStyles: ( 5 | selector: string, 6 | style: Style, 7 | modifiers?: string[], 8 | ) => { 9 | hash: string; 10 | usedModifiers?: string[]; 11 | allModifiers?: string[]; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/stringHash/__tests__/stringHash.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { murmurhash2 } from '..'; 4 | 5 | describe('getStringHash', () => { 6 | it('Return correct string hash for a string in murmurhash2', () => { 7 | expect(murmurhash2('1234567890qwertyuiopasdfghjklzxcvbnm[];')).be.eq('3eesko'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/stringHash/index.ts: -------------------------------------------------------------------------------- 1 | export function murmurhash2(str: string) { 2 | let l = str.length; 3 | let h = l ^ l; 4 | let i = 0; 5 | let k; 6 | 7 | while (l >= 4) { 8 | k = 9 | (str.charCodeAt(i) & 0xff) | 10 | ((str.charCodeAt(++i) & 0xff) << 8) | 11 | ((str.charCodeAt(++i) & 0xff) << 16) | 12 | ((str.charCodeAt(++i) & 0xff) << 24); 13 | 14 | k = (k & 0xffff) * 0x5bd1e995 + ((((k >>> 16) * 0x5bd1e995) & 0xffff) << 16); 15 | k ^= k >>> 24; 16 | k = (k & 0xffff) * 0x5bd1e995 + ((((k >>> 16) * 0x5bd1e995) & 0xffff) << 16); 17 | 18 | h = ((h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16)) ^ k; 19 | 20 | l -= 4; 21 | ++i; 22 | } 23 | 24 | /* eslint-disable no-fallthrough */ 25 | switch (l) { 26 | case 3: 27 | h ^= (str.charCodeAt(i + 2) & 0xff) << 16; 28 | case 2: 29 | h ^= (str.charCodeAt(i + 1) & 0xff) << 8; 30 | case 1: 31 | h ^= str.charCodeAt(i) & 0xff; 32 | h = (h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16); 33 | } 34 | /* eslint-enable no-fallthrough */ 35 | 36 | h ^= h >>> 13; 37 | h = (h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16); 38 | h ^= h >>> 15; 39 | 40 | return (h >>> 0).toString(36); 41 | } 42 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/webpack/common.ts: -------------------------------------------------------------------------------- 1 | export const STYLE_DESCRIPTOR = 'STYLE_DESCRIPTOR'; 2 | -------------------------------------------------------------------------------- /src/framework/infrastructure/css/webpack/store.ts: -------------------------------------------------------------------------------- 1 | import { CSSServerProviderStore } from '../provider/serverStore'; 2 | 3 | class CSSInJSStoreForWebpack extends CSSServerProviderStore { 4 | public clearStore() { 5 | this.mutableProccesedStyles = {}; 6 | } 7 | } 8 | 9 | const storeInstance = new CSSInJSStoreForWebpack(); 10 | 11 | export { storeInstance }; 12 | -------------------------------------------------------------------------------- /src/framework/infrastructure/lazy/retry.ts: -------------------------------------------------------------------------------- 1 | import { noopFunc } from 'lib/lodash'; 2 | 3 | /** 4 | * Any dynamic import retry creator 5 | * 6 | * @example 7 | * 8 | * const anyDynamicImportRetry = dynamicImportRetryCreator(); 9 | * const lodash = anyDynamicImportRetry(() => import('lodash')) 10 | * 11 | * function foo() { 12 | * lodash.then((_) => _.join(['Hello', 'World'], ''); 13 | * } 14 | */ 15 | export function dynamicImportRetryCreator( 16 | loader: () => Promise, 17 | options?: { 18 | retriesCount?: number; 19 | retriesInterval?: number; 20 | onError?: (error: Error) => void; 21 | }, 22 | ): () => Promise { 23 | const { retriesCount = 5, retriesInterval = 1000, onError = noopFunc } = options || {}; 24 | 25 | return function retry(retriesLeft = retriesCount, interval = retriesInterval) { 26 | return new Promise((resolve, reject) => { 27 | loader() 28 | .then(resolve) 29 | .catch((error: Error) => { 30 | onError(error); 31 | 32 | if (retriesLeft === 0) { 33 | return reject(error); 34 | } 35 | 36 | setTimeout(() => { 37 | const nextTimeoutMs = interval + interval * (retriesCount - retriesLeft); 38 | 39 | retry(retriesLeft - 1, nextTimeoutMs).then(resolve, reject); 40 | }, interval); 41 | }); 42 | }); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/framework/infrastructure/logger/clientLog.ts: -------------------------------------------------------------------------------- 1 | import { devConsoleLog } from 'lib/console/devConsole'; 2 | 3 | import { AppLogger } from '.'; 4 | import { getMessageAndStackParamsFromError } from './utils'; 5 | 6 | export const createClientGlobalErrorHandlers = (appLogger: AppLogger) => { 7 | return { 8 | logClientUncaughtException(error: Error) { 9 | devConsoleLog('logClientUncaughtException error: ', error); 10 | 11 | const { message, stack } = getMessageAndStackParamsFromError(error); 12 | 13 | appLogger.sendErrorLog({ 14 | message: message || 'logClientUncaughtException default error message', 15 | id: 'ujbvfg', 16 | source: 'windowerror', 17 | stack, 18 | }); 19 | }, 20 | 21 | logClientUnhandledRejection(error?: Error) { 22 | devConsoleLog('logClientUnhandledRejection error: ', error); 23 | 24 | if (!error) { 25 | appLogger.sendErrorLog({ 26 | id: 'huemld', 27 | message: 'No reason in UnhandledRejection', 28 | source: 'unhandledrejection', 29 | }); 30 | return; 31 | } 32 | 33 | const { message, stack } = getMessageAndStackParamsFromError(error); 34 | 35 | appLogger.sendErrorLog({ 36 | id: 'vhthav', 37 | message: message || 'logClientUnhandledRejection default error message', 38 | stack, 39 | source: 'unhandledrejection', 40 | }); 41 | }, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/framework/infrastructure/logger/init/client.ts: -------------------------------------------------------------------------------- 1 | import BaseLogger from 'pino'; 2 | 3 | export default {} as ReturnType; 4 | -------------------------------------------------------------------------------- /src/framework/infrastructure/logger/init/index.ts: -------------------------------------------------------------------------------- 1 | import BaseLogger from 'pino'; 2 | 3 | let logger: ReturnType; 4 | 5 | if (process.env.APP_ENV === 'client') { 6 | logger = require('./client').default; 7 | } else { 8 | logger = require('./server').default; 9 | } 10 | 11 | export { logger }; 12 | -------------------------------------------------------------------------------- /src/framework/infrastructure/logger/init/server.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino'; 2 | const isProduction = process.env.NODE_ENV === 'production'; 3 | 4 | function initLogger() { 5 | const loggerInstance = pino({ 6 | base: null, 7 | messageKey: 'message', 8 | timestamp: () => `,"timestamp":"${new Date().toISOString()}"`, 9 | formatters: { 10 | level(label) { 11 | return { pino_level: label }; 12 | }, 13 | }, 14 | transport: isProduction 15 | ? { 16 | target: 'pino/file', 17 | } 18 | : { 19 | target: 'pino-pretty', 20 | options: { 21 | colorize: true, 22 | }, 23 | }, 24 | }); 25 | 26 | return loggerInstance; 27 | } 28 | 29 | export default initLogger(); 30 | -------------------------------------------------------------------------------- /src/framework/infrastructure/logger/react/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import { noopFunc } from 'lib/lodash'; 4 | 5 | import { AppLogger } from '..'; 6 | 7 | export const AppLoggerContext = createContext({ 8 | sendFatalErrorLog: noopFunc, 9 | sendErrorLog: noopFunc, 10 | sendInfoLog: noopFunc, 11 | sendPerfomanceLog: noopFunc, 12 | }); 13 | -------------------------------------------------------------------------------- /src/framework/infrastructure/logger/react/hook.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { AppLoggerContext } from './context'; 4 | 5 | export const useAppLogger = () => { 6 | return useContext(AppLoggerContext); 7 | }; 8 | -------------------------------------------------------------------------------- /src/framework/infrastructure/logger/stub.ts: -------------------------------------------------------------------------------- 1 | import { stub } from 'sinon'; 2 | 3 | import { AppLogger } from '.'; 4 | 5 | export const appLoggerStub: AppLogger = { 6 | sendFatalErrorLog: stub(), 7 | sendErrorLog: stub(), 8 | sendInfoLog: stub(), 9 | sendPerfomanceLog: stub(), 10 | }; 11 | -------------------------------------------------------------------------------- /src/framework/infrastructure/logger/types.ts: -------------------------------------------------------------------------------- 1 | export type AnyLogParams = { 2 | /** 3 | * Uniq ID [a-z0-9]{5} of the log message. 4 | * Generate it here: https://www.random.org/strings/?num=50&len=5&digits=on&loweralpha=on&unique=on&format=plain&rnd=new 5 | */ 6 | id: string; 7 | }; 8 | 9 | export type InfoLogParams = { 10 | message: string; 11 | data?: Record; 12 | } & AnyLogParams; 13 | 14 | export interface PerformanceLogParams { 15 | waiting: number; 16 | download: number; 17 | scriptLoading: number; 18 | interactive: number; 19 | complete: number; 20 | full: number; 21 | } 22 | 23 | export type ErrorLogParams = { 24 | message?: string; 25 | source: 26 | | 'router' 27 | | 'application_bootstrap' 28 | | 'windowerror' 29 | | 'unhandledrejection' 30 | | 'service' 31 | | 'unknown'; 32 | stack?: string; 33 | lineNumber?: number; 34 | columnNumber?: number; 35 | data?: Record; 36 | } & AnyLogParams; 37 | 38 | export type FatalLogParams = { 39 | message?: string; 40 | stack?: string; 41 | lineNumber?: number; 42 | columnNumber?: number; 43 | data?: Record; 44 | } & AnyLogParams; 45 | -------------------------------------------------------------------------------- /src/framework/infrastructure/logger/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Each app has its own version, based on git commit hash (recommended way) 3 | * This param can be used to understand, which version of the app is used on any user device 4 | */ 5 | export function addAppVersion() { 6 | return { 7 | appVersion: process.env.APP_VERSION || 'undefined_version', 8 | }; 9 | } 10 | 11 | export function getMessageAndStackParamsFromError( 12 | error: Error, 13 | options?: { 14 | defaultMessage?: string; 15 | stackSize?: number; 16 | }, 17 | ): { 18 | message: string; 19 | stack: string; 20 | } { 21 | const opts = { 22 | defaultMessage: '', 23 | stackSize: 2048, 24 | ...options, 25 | }; 26 | const message = (error && error.toString()) || opts.defaultMessage; 27 | const stack = (error && error.stack && error.stack.toString()) || 'empty stack'; 28 | 29 | return { message, stack: stack.slice(0, opts.stackSize) }; 30 | } 31 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/cookie/client.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore next */ 2 | import { createCookieAPI as createClientCookieAPI } from 'lib/cookies/client'; 3 | 4 | /* istanbul ignore next */ 5 | export const createCookieAPI = createClientCookieAPI; 6 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/cookie/server.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore next */ 2 | import { createCookieAPI as createServerCookieAPI } from 'lib/cookies/server'; 3 | 4 | /* istanbul ignore next */ 5 | export const createCookieAPI = createServerCookieAPI; 6 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/cookie/stub/index.ts: -------------------------------------------------------------------------------- 1 | import { createMethodStubber } from 'framework/infrastructure/tests/stub'; 2 | 3 | import { createCookieAPI } from '../client'; 4 | 5 | export const createStubedCookieAPI = () => { 6 | const stubMethod = createMethodStubber('cookie'); 7 | 8 | return { 9 | get: stubMethod['get']>('get'), 10 | set: stubMethod['set']>('set'), 11 | delete: stubMethod['delete']>('delete'), 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/cookie/types.ts: -------------------------------------------------------------------------------- 1 | import { Cookie } from 'lib/cookies/types'; 2 | 3 | export type CookieAPI = Cookie; 4 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/index.ts: -------------------------------------------------------------------------------- 1 | import { CookieAPI } from './cookie/types'; 2 | import { WindowAPI } from './window/types'; 3 | 4 | /** 5 | * This is a wrapper for all platform dependent APIs like cookie and window 6 | */ 7 | /* istanbul ignore next */ 8 | export const createPlatformAPI = (params: { envSpecificAPIs: EnvSpecificAPIs }) => { 9 | const { envSpecificAPIs } = params; 10 | 11 | return { 12 | window: envSpecificAPIs.window, 13 | cookies: envSpecificAPIs.cookies, 14 | }; 15 | }; 16 | 17 | export type EnvSpecificAPIs = { 18 | cookies: CookieAPI; 19 | window: WindowAPI; 20 | }; 21 | 22 | export type PlatformAPI = ReturnType; 23 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/shared/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | import { PlatformAPI } from '..'; 4 | 5 | export const PlatformAPIContext = createContext({} as PlatformAPI); 6 | 7 | export const usePlatformAPI = () => { 8 | const platformAPI = useContext(PlatformAPIContext); 9 | 10 | return platformAPI; 11 | }; 12 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/stubedPlatformAPICreator.ts: -------------------------------------------------------------------------------- 1 | import { createStubedCookieAPI } from './cookie/stub'; 2 | import { createStubedWindowAPI } from './window/stub'; 3 | 4 | export type StubedPlatformAPIs = ReturnType; 5 | 6 | /* istanbul ignore next */ 7 | export const createStubedPlatformAPIs = () => ({ 8 | cookies: createStubedCookieAPI(), 9 | window: createStubedWindowAPI(), 10 | }); 11 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/window/client.ts: -------------------------------------------------------------------------------- 1 | import { WindowAPI } from './types'; 2 | 3 | export const createWindowApi = (window: Window): WindowAPI => { 4 | const client = window; 5 | 6 | return { 7 | getLocationHref() { 8 | return client.location.href; 9 | }, 10 | 11 | changeLocationHref(url: string) { 12 | // eslint-disable-next-line functional/immutable-data 13 | client.location.href = url; 14 | }, 15 | 16 | open(url, name, specs) { 17 | return client.open(url, name, specs); 18 | }, 19 | reload() { 20 | client.location.reload(); 21 | }, 22 | delayClose(time = 0) { 23 | client.setTimeout(() => client.close(), time); 24 | }, 25 | 26 | historyPush(state, url) { 27 | client.history.pushState(state, '', url); 28 | }, 29 | 30 | historyReplace(state, url) { 31 | client.history.replaceState(state, '', url); 32 | }, 33 | 34 | historyBack() { 35 | client.history.back(); 36 | }, 37 | 38 | setTitle(title) { 39 | client.document.title = title; 40 | }, 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/window/server.ts: -------------------------------------------------------------------------------- 1 | import { WindowAPI } from './types'; 2 | 3 | export const createWindowApi = (): WindowAPI => ({ 4 | getLocationHref() { 5 | return ''; 6 | }, 7 | 8 | changeLocationHref(_url: string) { 9 | // do nothing 10 | }, 11 | 12 | delayClose() { 13 | // do nothing 14 | }, 15 | 16 | open() { 17 | // null cause client window returns Window | null 18 | return null; 19 | }, 20 | reload(_forcedReload) { 21 | // do nothing 22 | }, 23 | 24 | historyBack() { 25 | // do nothing 26 | }, 27 | historyPush() { 28 | // do nothing 29 | }, 30 | historyReplace() { 31 | // do nothing 32 | }, 33 | setTitle() { 34 | // do nothing 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/window/stub/index.ts: -------------------------------------------------------------------------------- 1 | import { createMethodStubber } from 'framework/infrastructure/tests/stub'; 2 | 3 | import { WindowAPI } from '../types'; 4 | 5 | export const createStubedWindowAPI = () => { 6 | const stubMethod = createMethodStubber('window'); 7 | 8 | return { 9 | delayClose: stubMethod('delayClose'), 10 | historyBack: stubMethod('historyBack'), 11 | historyPush: stubMethod('historyPush'), 12 | historyReplace: stubMethod('historyReplace'), 13 | open: stubMethod('open'), 14 | reload: stubMethod('reload'), 15 | mocks: {}, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/framework/infrastructure/platform/window/types.ts: -------------------------------------------------------------------------------- 1 | export interface WindowAPI { 2 | delayClose(time?: number): void; 3 | getLocationHref(): string; 4 | changeLocationHref(url: string): void; 5 | open(url?: string, name?: string, specs?: string): Window | null; 6 | reload(forcedReload?: boolean): void; 7 | 8 | historyBack(): void; 9 | historyPush(state: any, url: string): void; 10 | historyReplace(state: any, url: string): void; 11 | 12 | setTitle(title: string): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/framework/infrastructure/query/defaultOptions.ts: -------------------------------------------------------------------------------- 1 | import { DefaultOptions, keepPreviousData } from '@tanstack/react-query'; 2 | 3 | /** 4 | * Just some reasonable default options for react-query 5 | */ 6 | export const defaultQueryOptions: DefaultOptions = { 7 | queries: { 8 | networkMode: 'offlineFirst', 9 | refetchOnWindowFocus: false, 10 | refetchOnReconnect: true, 11 | /** 12 | * @TODO check this: 13 | * 14 | * {!ifFetching && 15 | * 16 | */ 17 | refetchOnMount: true, 18 | 19 | placeholderData: keepPreviousData, 20 | 21 | /** 22 | * Actually, its ok to change this option to 3 times 23 | */ 24 | retry: false, 25 | 26 | /** 27 | * Works for useQuery, useInfinityQuery and mutations only 28 | * 29 | * For useSuspense and useSuspenseInfinityQuery with option is useless 30 | * https://tanstack.com/query/latest/docs/react/guides/suspense#throwonerror-default 31 | */ 32 | throwOnError: false, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /src/framework/infrastructure/query/getDehydratedQueryStateFromDom.ts: -------------------------------------------------------------------------------- 1 | export const getDehydratedQueryStateFromDom = (queryId: string) => { 2 | if (process.env.APP_ENV === 'server') { 3 | return; 4 | } 5 | 6 | const isHydratedQueryKey = queryId + '_hydrated'; 7 | 8 | if ((window as any)[isHydratedQueryKey]) { 9 | return; 10 | } 11 | 12 | /** 13 | * dehydratedQueryState is in the dom via DehydrateQueryWritable 14 | * For more info checkout ReactStreamRenderEnhancer 15 | */ 16 | const dehydratedQueryState = (window as any)[queryId]; 17 | 18 | /** 19 | * Needs to prevent useless hydration processes after the first hydration 20 | */ 21 | if (dehydratedQueryState) { 22 | // eslint-disable-next-line functional/immutable-data 23 | (window as any)[isHydratedQueryKey] = true; 24 | } 25 | 26 | return dehydratedQueryState; 27 | }; 28 | -------------------------------------------------------------------------------- /src/framework/infrastructure/query/types.ts: -------------------------------------------------------------------------------- 1 | import { ParsedError } from 'framework/infrastructure/request/types'; 2 | 3 | /** 4 | * This are some additional options, 5 | * which are useful for the react-query wrapper of the framework 6 | */ 7 | export type AnyCommonFrameworkQueryOptions = { 8 | /** 9 | * Checkout `useResetCacheOnUnmount.ts` for more details 10 | * Be carefull! You have to set with function in options only one time 11 | * during a query lifetime 12 | */ 13 | isErrorCodeOkToResetCache?: (errorCode: ParsedError['code']) => boolean; 14 | }; 15 | -------------------------------------------------------------------------------- /src/framework/infrastructure/query/useAnyAppSuspenseInfiniteQuery.ts: -------------------------------------------------------------------------------- 1 | import { 2 | hashKey, 3 | QueryKey, 4 | UseSuspenseInfiniteQueryOptions, 5 | useSuspenseInfiniteQuery, 6 | InfiniteData, 7 | } from '@tanstack/react-query'; 8 | 9 | import { ParsedError } from 'framework/infrastructure/request/types'; 10 | 11 | import { AnyCommonFrameworkQueryOptions } from './types'; 12 | import { useHydrateQuery } from './useHydrateQuery'; 13 | import { useResetCacheOnUnmount } from './useResetCacheOnUnmount'; 14 | 15 | export type UseAnyAppSuspenseInfiniteQueryOptions< 16 | TResult, 17 | TError extends ParsedError, 18 | QKey extends QueryKey, 19 | TPageParam, 20 | > = UseSuspenseInfiniteQueryOptions< 21 | TResult, 22 | TError, 23 | InfiniteData, 24 | TResult, 25 | QKey, 26 | TPageParam 27 | > & { 28 | frameworkQueryOptions?: AnyCommonFrameworkQueryOptions; 29 | }; 30 | 31 | /** 32 | * A tiny wrapper around useSuspenseInfiniteQuery 33 | * Purpose: 34 | * 1. dehydrate a query state on client side in case of a stream rendering 35 | */ 36 | /** @TODO may be change type of the error? 37 | * What if an Error will be thrown during response parse? */ 38 | export const useAnyAppSuspenseInfiniteQuery = < 39 | TResult, 40 | TError extends ParsedError, 41 | QKey extends QueryKey, 42 | TPageParam, 43 | >( 44 | queryOptions: UseAnyAppSuspenseInfiniteQueryOptions, 45 | ) => { 46 | const { frameworkQueryOptions } = queryOptions; 47 | const queryId = hashKey(queryOptions.queryKey); 48 | 49 | useHydrateQuery(queryId); 50 | useResetCacheOnUnmount({ 51 | key: queryOptions.queryKey, 52 | queryId, 53 | isErrorCodeOkToResetCacheCheck: frameworkQueryOptions?.isErrorCodeOkToResetCache, 54 | }); 55 | 56 | return useSuspenseInfiniteQuery, QKey, TPageParam>( 57 | queryOptions, 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/framework/infrastructure/query/useAnyAppSuspenseQuery.ts: -------------------------------------------------------------------------------- 1 | import { hashKey, QueryKey, useSuspenseQuery, UseSuspenseQueryOptions } from '@tanstack/react-query'; 2 | 3 | import { ParsedError } from 'framework/infrastructure/request/types'; 4 | 5 | import { AnyCommonFrameworkQueryOptions } from './types'; 6 | import { useHydrateQuery } from './useHydrateQuery'; 7 | import { useResetCacheOnUnmount } from './useResetCacheOnUnmount'; 8 | 9 | export type UseAnyAppSuspenseQueryOptions< 10 | TResult, 11 | TError extends ParsedError, 12 | QKey extends QueryKey, 13 | > = UseSuspenseQueryOptions & { 14 | frameworkQueryOptions?: AnyCommonFrameworkQueryOptions; 15 | }; 16 | /** 17 | * A tiny wrapper around useSuspenseQuery 18 | * Purpose: 19 | * 1. dehydrate a query state on client side in case of a stream rendering 20 | */ 21 | /** @TODO may be change type of the error? 22 | * What if an Error will be thrown during response parse? */ 23 | export const useAnyAppSuspenseQuery = ( 24 | queryOptions: UseAnyAppSuspenseQueryOptions, 25 | ) => { 26 | const { queryKey, frameworkQueryOptions } = queryOptions; 27 | const queryId = hashKey(queryKey); 28 | 29 | useHydrateQuery(queryId); 30 | useResetCacheOnUnmount({ 31 | key: queryKey, 32 | queryId, 33 | isErrorCodeOkToResetCacheCheck: frameworkQueryOptions?.isErrorCodeOkToResetCache, 34 | }); 35 | 36 | return useSuspenseQuery(queryOptions); 37 | }; 38 | -------------------------------------------------------------------------------- /src/framework/infrastructure/query/useHydrateQuery.ts: -------------------------------------------------------------------------------- 1 | import { hydrate, useQueryClient } from '@tanstack/react-query'; 2 | 3 | import { getDehydratedQueryStateFromDom } from './getDehydratedQueryStateFromDom'; 4 | 5 | /** 6 | * Purposes: 7 | * 1. dehydrate a query state on client side in case of a stream rendering 8 | */ 9 | export const useHydrateQuery = (queryId: string) => { 10 | const queryClient = useQueryClient(); 11 | /** 12 | * queryId is used as an id for a div with data for a dehydration process 13 | * The data will be there after react-component, which uses current query, 14 | * was rendered and was streamed to a client 15 | * 16 | * For more info checkout ReactStreamRenderEnhancer 17 | */ 18 | const dehydratedQueryState = getDehydratedQueryStateFromDom(queryId); 19 | 20 | if (dehydratedQueryState) { 21 | hydrate(queryClient, dehydratedQueryState); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/framework/infrastructure/query/useResetCacheOnUnmount.ts: -------------------------------------------------------------------------------- 1 | import { QueryKey, useQueryClient } from '@tanstack/react-query'; 2 | import { useEffect } from 'react'; 3 | 4 | import { ParsedError } from '../request/types'; 5 | 6 | type Params = { 7 | key: QueryKey; 8 | queryId: string; 9 | isErrorCodeOkToResetCacheCheck?: (errorCode: ParsedError['code']) => boolean; 10 | }; 11 | /** 12 | * Reset cache for a query, if it is in a error state on a component unmount 13 | */ 14 | export const useResetCacheOnUnmount = ({ 15 | key, 16 | queryId, 17 | isErrorCodeOkToResetCacheCheck, 18 | }: Params) => { 19 | const queryClient = useQueryClient(); 20 | const isErrorCodeOkToResetCacheChecker = 21 | isErrorCodeOkToResetCacheCheck || defaultIsErrorCodeOkToResetCacheCheck; 22 | 23 | useEffect(() => { 24 | return () => { 25 | const queryState = queryClient.getQueryState(key); 26 | 27 | if (queryState?.status !== 'error') { 28 | return; 29 | } 30 | 31 | const errorCode = queryState?.error && queryState?.error.code; 32 | const isErrorCodeOkToResetCache = errorCode ? isErrorCodeOkToResetCacheChecker(errorCode) : true; 33 | 34 | if (isErrorCodeOkToResetCache) { 35 | queryClient.getQueryCache().find({ exact: true, queryKey: key })?.reset(); 36 | } 37 | }; 38 | // We use queryId as compiled key here, 39 | // cause key is a new array every render 40 | // eslint-disable-next-line react-hooks/exhaustive-deps 41 | }, [queryClient, queryId, isErrorCodeOkToResetCacheChecker]); 42 | }; 43 | 44 | /** 45 | * It's quite useless to retry a request with 404 response 46 | */ 47 | const defaultIsErrorCodeOkToResetCacheCheck = (errorCode: ParsedError['code']) => { 48 | return errorCode !== 404; 49 | }; 50 | -------------------------------------------------------------------------------- /src/framework/infrastructure/raise/react/component.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import { useRaiseError } from './context'; 4 | 5 | type Props = { 6 | code: number; 7 | }; 8 | 9 | /** 10 | * This component allows to raise an error code to the res.status 11 | * from render of any component. 12 | * An example, you're trying to render a component, where react-query is used. 13 | * Let's imagine, that your query returns a error as a result. 14 | * You'd like to return a correct status for document request for search bots. 15 | * So, you can render this component in such situation. 16 | * `raiseError` set the error code in a curent render context 17 | * and this error code will be set as a status code 18 | */ 19 | export const RaiseError = memo(({ code }) => { 20 | const { raiseError } = useRaiseError(); 21 | 22 | raiseError(code); 23 | 24 | return null; 25 | }); 26 | -------------------------------------------------------------------------------- /src/framework/infrastructure/raise/react/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | export const RaiseErrorContext = createContext({ 4 | raiseError: (_errorCode: number) => { 5 | /** */ 6 | }, 7 | }); 8 | 9 | /** 10 | * Returns the `raiseError` function, that allows to raise an error code to the res.status 11 | * from render of any component. 12 | */ 13 | export const useRaiseError = () => useContext(RaiseErrorContext); 14 | -------------------------------------------------------------------------------- /src/framework/infrastructure/raise/store.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a store, which store an error code, which is raised during current render 3 | */ 4 | export const createRaiseErrorStore = () => { 5 | let raisedError: number | undefined = undefined; 6 | 7 | const raiseError = (httpCode: number) => { 8 | raisedError = httpCode; 9 | }; 10 | 11 | const getRaisedError = () => { 12 | return raisedError; 13 | }; 14 | 15 | return { 16 | raiseError, 17 | getRaisedError, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/framework/infrastructure/request/__tests__/patchUrlProtocol.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { patchUrlProtocol } from '../utils/patchUrlProtocol'; 4 | 5 | describe('patchUrlProtocol', () => { 6 | it('add http for protocol-less url', () => { 7 | const url = '//domain.com'; 8 | const result = 'http://domain.com'; 9 | 10 | expect(patchUrlProtocol(url)).to.be.eq(result); 11 | }); 12 | 13 | it('nothing to change in url with protocol', () => { 14 | const url = 'https://domain.com'; 15 | const result = 'https://domain.com'; 16 | 17 | expect(patchUrlProtocol(url)).to.be.eq(result); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/framework/infrastructure/request/error.ts: -------------------------------------------------------------------------------- 1 | type CreateRequesteErrorParams = { 2 | response: Response; 3 | originalError?: Error; 4 | parsedBody?: string; 5 | }; 6 | 7 | export class RequestError extends Error { 8 | static isRequestError = true; 9 | 10 | public response: Response; 11 | public originalError?: Error; 12 | public parsedBody?: string; 13 | 14 | constructor({ response, originalError, parsedBody }: CreateRequesteErrorParams) { 15 | super(); 16 | 17 | this.response = response; 18 | this.originalError = originalError; 19 | this.parsedBody = parsedBody; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/framework/infrastructure/request/utils/abortController.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates combined abort controller, 3 | * which includes timeout controller and a custom user controller 4 | */ 5 | export function createCombinedAbortController(networkTimeout: number, userSignal?: AbortSignal | null) { 6 | const controller = new AbortController(); 7 | const timeoutId = setTimeout(() => { 8 | if (!controller.signal.aborted) { 9 | controller.abort(); 10 | } 11 | }, networkTimeout); 12 | const mutableSignals = [controller.signal]; 13 | 14 | if (userSignal) { 15 | mutableSignals.push(userSignal); 16 | } 17 | 18 | const signal = abortAny(mutableSignals); 19 | 20 | return { 21 | signal, 22 | cancelTimeoutAbort: () => clearTimeout(timeoutId), 23 | }; 24 | } 25 | 26 | export function getStatusFromAbortReason(reason: AbortSignal['reason'], fallback: number): number { 27 | return 'status' in reason && typeof reason.status === 'number' ? reason.status : fallback; 28 | } 29 | 30 | export function getStatusTextFromAbortReason(reason: AbortSignal['reason'], fallback: string): string { 31 | if (reason instanceof DOMException) { 32 | return 'Network connect timeout error'; 33 | } 34 | 35 | if ('statusText' in reason && typeof reason.statusText === 'string') { 36 | return reason.statusText || fallback; 37 | } 38 | 39 | return fallback; 40 | } 41 | 42 | function abortAny(signals: AbortSignal[]) { 43 | const controller = new AbortController(); 44 | 45 | signals.forEach((signal) => { 46 | if (signal.aborted) { 47 | controller.abort(); 48 | } else { 49 | signal.addEventListener('abort', () => controller.abort(), { once: true }); 50 | } 51 | }); 52 | 53 | return controller.signal; 54 | } 55 | -------------------------------------------------------------------------------- /src/framework/infrastructure/request/utils/generateRequestId.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | 3 | /* istanbul ignore next */ 4 | export function generateRequestId(): string { 5 | return process.env.NODE_ENV === 'test' ? 'requestId' : v4(); 6 | } 7 | -------------------------------------------------------------------------------- /src/framework/infrastructure/request/utils/getRequestContentType.ts: -------------------------------------------------------------------------------- 1 | import { isBlob, isPlainObject, isURLSearchParameters } from './is'; 2 | import { RequestParams } from '../types'; 3 | 4 | /** 5 | * Performs the operation "extract a `Content-Type` value from |object|" as 6 | * specified in the specification: 7 | * https://fetch.spec.whatwg.org/#concept-bodyinit-extract 8 | * 9 | * @param {any} body Any options.body input 10 | * @returns {string | null} 11 | */ 12 | export const getRequestContentType = (body?: RequestParams['body']): string | null => { 13 | // Body is a string 14 | if (typeof body === 'string') { 15 | return 'text/plain;charset=UTF-8'; 16 | } 17 | 18 | // Body is a URLSearchParams 19 | if (isURLSearchParameters(body)) { 20 | return 'application/x-www-form-urlencoded;charset=UTF-8'; 21 | } 22 | 23 | // Body is a blob 24 | if (isBlob(body)) { 25 | return body.type || null; 26 | } 27 | 28 | // Body is a plain object 29 | if (isPlainObject(body)) { 30 | return 'application/json;charset=UTF-8'; 31 | } 32 | 33 | // For all other cases let a browser to make a choice 34 | return null; 35 | }; 36 | -------------------------------------------------------------------------------- /src/framework/infrastructure/request/utils/patchUrlProtocol.ts: -------------------------------------------------------------------------------- 1 | import { isServer } from 'lib/browser'; 2 | 3 | /** 4 | * Add http for server-side requests to protocol-less urls 5 | */ 6 | export function patchUrlProtocol(url: string) { 7 | if (isServer && !url.indexOf('//')) { 8 | return `http:${url}`; 9 | } 10 | 11 | return url; 12 | } 13 | -------------------------------------------------------------------------------- /src/framework/infrastructure/request/utils/response.ts: -------------------------------------------------------------------------------- 1 | type Params = { 2 | status: number; 3 | statusText?: string; 4 | body?: any; 5 | headers?: Record; 6 | }; 7 | export function createResponse(params: Params) { 8 | return new global.Response(params.body, { 9 | status: params.status, 10 | statusText: params.statusText || 'Unknonwn error', 11 | headers: params.headers, 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/framework/infrastructure/request/utils/tests.ts: -------------------------------------------------------------------------------- 1 | import { createResponse } from './response'; 2 | 3 | type CreateJsonResponseParams = { 4 | status: number; 5 | body: string; 6 | statusText?: string; 7 | headers?: Record; 8 | }; 9 | export function createJsonResponse({ status, statusText, body, headers }: CreateJsonResponseParams) { 10 | return Promise.resolve( 11 | createResponse({ 12 | status, 13 | statusText: statusText || 'Default status text', 14 | body, 15 | headers: { 16 | 'Content-Type': 'application/json; charset=utf-8;', 17 | ...headers, 18 | }, 19 | }), 20 | ); 21 | } 22 | 23 | export function createOkJsonResponse(body: string) { 24 | return createJsonResponse({ 25 | status: 200, 26 | body, 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { normalizePath } from '../utils'; 4 | 5 | describe('router utils', () => { 6 | describe('normalizePath', () => { 7 | it('normalizePath correctly normalize an original path', () => { 8 | const series: Array<[string, string]> = [ 9 | ['/', '/'], 10 | ['/user', '/user'], 11 | ['/user/:id', '/user/:p'], 12 | ['/user/:name', '/user/:p'], 13 | ['/user/:name/:id?', '/user/:p'], 14 | ['/user/:name?', '/user'], 15 | ['/user/:name?/:id?', '/user'], 16 | ['/user/:id/:name', '/user/:p/:p'], 17 | ]; 18 | 19 | series.forEach((testCase) => { 20 | expect(normalizePath(testCase[0])).be.eq(testCase[1]); 21 | }); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/createRouteConfigCreator.ts: -------------------------------------------------------------------------------- 1 | import { AnyPage, RouteConfig, RouteWithoutParams, RouteWithParams } from './types'; 2 | 3 | /** 4 | * Original type "Route" has a lot of Generic params, 5 | * so it can be not to friendly to use it in a real app. 6 | * 7 | * So, we have a fabric here to make it much easy to create a config for any route. 8 | * Just specify MatchedPage and RouteParams, if you have it 9 | * 10 | * AppPage — is a type for any page for the certain application 11 | * ErrorPage — is a type (can be union, if you need) for an error page for the certain application 12 | */ 13 | export function createRouteConfigCreator, ErrorPage extends AppPage>() { 14 | return < 15 | // A matched page, cause we create any route to open a certain page 16 | MatchedPage extends AppPage, 17 | // An union type to specify names for all used path params 18 | RoutePathParamNames extends string = never, 19 | // Just a service type for a developer 20 | // Allows to not specify names for path params, if the page does not have it 21 | RoutePathParams = Record, 22 | >( 23 | route: RouteConfig< 24 | // We can infer here, if that config uses params from a path 25 | keyof RoutePathParams extends never 26 | ? RouteWithoutParams 27 | : RouteWithParams>, 28 | AppPage, 29 | MatchedPage, 30 | ErrorPage 31 | >, 32 | ) => route; 33 | } 34 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/hooks/useAnyActivePage.ts: -------------------------------------------------------------------------------- 1 | import { useRouterReduxSelector } from '../redux/hooks'; 2 | import { AnyAppContext, AnyAppState, AnyPage } from '../types'; 3 | 4 | const selectAnyActivePage = (state: S): S['appContext']['page'] => 5 | state.appContext.page; 6 | 7 | export const useAnyActivePage = >() => { 8 | return useRouterReduxSelector>, Page>(selectAnyActivePage); 9 | }; 10 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/hooks/useCommonNavigate.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { commonWithSelectors, noop, sequence } from 'framework/infrastructure/signal'; 4 | 5 | import { useRouterReduxDispatch } from '../redux/hooks'; 6 | import { selectAnyPage } from '../redux/selectors'; 7 | import { patchPageSignal } from '../redux/signals/page'; 8 | import { AnyPage } from '../types'; 9 | 10 | export const useCommonNavigate = >() => { 11 | const dispatch = useRouterReduxDispatch(); 12 | 13 | /** 14 | * Opens new pages and return a promise with that new page from a state 15 | * Just a wrapper around redux part of the routing 16 | * This wrapper allows to replace redux with something else, 17 | * without an application deep refactoring 18 | */ 19 | /** 20 | * Patches new page and return a promise with updated page from a state 21 | * Just a wrapper around redux part of the routing 22 | * This wrapper allows to replace redux with something else, 23 | * without an application deep refactoring 24 | */ 25 | const navigate = useCallback( 26 | ( 27 | destination: (activePage: AppPage) => DestinationPage, 28 | useReplace = false, 29 | ) => 30 | new Promise((resolve) => { 31 | dispatch( 32 | sequence( 33 | patchPageSignal(destination as any, useReplace), 34 | commonWithSelectors( 35 | { 36 | patchedPage: selectAnyPage, 37 | }, 38 | ({ patchedPage }) => { 39 | resolve(patchedPage as DestinationPage); 40 | return noop(); 41 | }, 42 | ), 43 | ), 44 | ); 45 | }), 46 | [dispatch], 47 | ); 48 | 49 | return { 50 | navigate, 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/hooks/useURLQueryParams.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { commonWithSelectors, sequence } from 'framework/infrastructure/signal'; 4 | 5 | import { ValidateStructure } from 'lib/types'; 6 | 7 | import { setQueryStringParamsAction } from '../redux/actions/appContext/setQueryStringParams'; 8 | import { historyReplace, historyPush } from '../redux/actions/router'; 9 | import { useRouterReduxSelector, useRouterReduxDispatch } from '../redux/hooks'; 10 | import { AnyAppState, URLQueryParams } from '../types'; 11 | 12 | const selectURLQueryParams = (state: AnyAppState) => state.appContext.URLQueryParams; 13 | 14 | export const useCommonURLQuery = () => { 15 | const URLQueryParams: URLQueryParams = useRouterReduxSelector(selectURLQueryParams); 16 | const dispatch = useRouterReduxDispatch(); 17 | 18 | const setURLQueryParams = useCallback( 19 | (params: { 20 | queryParams: ( 21 | currentURLQueryParams: URLQueryParams, 22 | ) => ValidateStructure>; 23 | useReplace?: boolean; 24 | }) => { 25 | const { queryParams, useReplace = false } = params; 26 | 27 | dispatch( 28 | sequence( 29 | commonWithSelectors( 30 | { currentURLQueryParams: selectURLQueryParams }, 31 | ({ currentURLQueryParams }) => 32 | setQueryStringParamsAction(queryParams(currentURLQueryParams)), 33 | ), 34 | useReplace ? historyReplace() : historyPush(), 35 | ), 36 | ); 37 | }, 38 | [dispatch], 39 | ); 40 | 41 | return { 42 | URLQueryParams, 43 | setURLQueryParams, 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/actions/appContext/openPageAction.ts: -------------------------------------------------------------------------------- 1 | import { AnyPage } from 'framework/infrastructure/router/types'; 2 | 3 | export const openAnyPageAction = >(payload: Page) => ({ 4 | type: 'openPageAction' as const, 5 | payload, 6 | }); 7 | 8 | export type OpenAnyPageActionType = ReturnType; 9 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/actions/appContext/setQueryStringParams.ts: -------------------------------------------------------------------------------- 1 | import { URLQueryParams } from 'framework/infrastructure/router/types'; 2 | 3 | export const setQueryStringParamsAction = (payload: URLQueryParams) => ({ 4 | type: 'setQueryStringParamsAction' as const, 5 | payload, 6 | }); 7 | 8 | export type SetQueryStringParamsActionType = ReturnType; 9 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/actions/router.ts: -------------------------------------------------------------------------------- 1 | import { AnyAppContext } from '../../types'; 2 | 3 | export const historyPush = () => ({ 4 | type: 'historyPush', 5 | payload: {}, 6 | }); 7 | 8 | export const historyReplace = () => ({ 9 | type: 'historyReplace', 10 | payload: {}, 11 | }); 12 | 13 | export const historyRedirect = () => ({ 14 | type: 'historyRedirect', 15 | payload: {}, 16 | }); 17 | 18 | export const historyBack = () => ({ 19 | type: 'historyBack', 20 | payload: {}, 21 | }); 22 | 23 | export const replaceState = (payload: AnyAppContext) => ({ 24 | type: 'replaceState' as const, 25 | payload, 26 | }); 27 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { createDispatchHook, createSelectorHook } from 'react-redux'; 2 | 3 | import { RouterReduxContext } from '../store/context'; 4 | 5 | export const useRouterReduxDispatch = createDispatchHook(RouterReduxContext); 6 | export const useRouterReduxSelector = createSelectorHook(RouterReduxContext); 7 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/middlewares/historyActons.ts: -------------------------------------------------------------------------------- 1 | import { AnyAppContext } from '../../types'; 2 | 3 | /** 4 | * 5 | */ 6 | export function push(url: string, appContext: AnyAppContext) { 7 | const nState = { 8 | appContext, 9 | }; 10 | 11 | if (currentUrl() === url) { 12 | return; 13 | } 14 | if (process.env.NODE_ENV !== 'production') { 15 | // eslint-disable-next-line no-console 16 | console.log(`%c➔ %c${decodeURIComponent(url)}`, 'color: #090', 'color: #999', appContext); 17 | } 18 | window.history.pushState(nState, '', url); 19 | } 20 | 21 | export function replace(url: string, appContext: AnyAppContext) { 22 | const nState = { 23 | appContext, 24 | }; 25 | 26 | if (process.env.NODE_ENV !== 'production') { 27 | // eslint-disable-next-line no-console 28 | console.log(`%c↺ %c${decodeURIComponent(url)}`, 'color: #900', 'color: #999', appContext); 29 | } 30 | window.history.replaceState(nState, '', url); 31 | } 32 | 33 | function currentUrl() { 34 | return decodeURIComponent(window.location.pathname + window.location.search); 35 | } 36 | 37 | export function back(url: string) { 38 | if (process.env.NODE_ENV !== 'production') { 39 | // eslint-disable-next-line no-console 40 | console.log(`%c← %c${decodeURIComponent(url)}`, 'color: #900', 'color: #999'); 41 | } 42 | window.history.back(); 43 | } 44 | 45 | export function onHistoryMove(cb: (appContext: AnyAppContext) => void) { 46 | window.onpopstate = (event: PopStateEvent) => { 47 | if (!event.state) { 48 | return; 49 | } 50 | 51 | if (process.env.NODE_ENV !== 'production') { 52 | // eslint-disable-next-line no-console 53 | console.log('[history] Popped state: ', event.state); 54 | } 55 | 56 | cb(event.state); 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, Middleware, MiddlewareAPI, isAction } from 'redux'; 2 | 3 | import { createNavigator } from './navigate'; 4 | import { URLCompiler } from '../../compileURL'; 5 | import { AnyAppState } from '../../types'; 6 | 7 | export function createNavigationMiddleware( 8 | URLCompiler: URLCompiler, 9 | ): Middleware, AnyAppState, Dispatch> { 10 | const navigate = createNavigator(URLCompiler); 11 | 12 | return (middlewareAPI: MiddlewareAPI) => (next) => (action) => { 13 | // To prevent calling navigate function in tests 14 | if (process.env.NODE_ENV === 'test') { 15 | return next(action); 16 | } 17 | 18 | if (isAction(action) && navigate(action, middlewareAPI)) { 19 | return action; 20 | } 21 | 22 | return next(action); 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/middlewares/navigate.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareAPI, Action, Dispatch } from 'redux'; 2 | 3 | import { isServer } from 'lib/browser'; 4 | 5 | import { push, replace, back } from './historyActons'; 6 | import { AnyAppState, AnyAppContext } from '../../types'; 7 | 8 | /** 9 | * Push or replace an application state to a browser history. 10 | * Returns true if server-side redirect occured, false otherwise. 11 | * On client, always returns false. 12 | */ 13 | export function createNavigator(compileURL: (appContext: AnyAppContext) => string) { 14 | return (action: Action, store: MiddlewareAPI): boolean => { 15 | if (!isServer) { 16 | const appContext = store.getState().appContext; 17 | const urlFromState = compileURL(appContext); 18 | 19 | switch (action.type) { 20 | case 'historyPush': 21 | push(urlFromState, appContext); 22 | return true; 23 | case 'historyBack': 24 | back(urlFromState); 25 | return true; 26 | case 'historyReplace': 27 | case 'historyRedirect': // on client, redirect means replace 28 | replace(urlFromState, appContext); 29 | return true; 30 | default: 31 | } 32 | } else { 33 | // separate handling of redirects on server 34 | if (action.type === 'historyRedirect') { 35 | return true; 36 | } 37 | } 38 | 39 | return false; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/selectors/index.ts: -------------------------------------------------------------------------------- 1 | import { AnyAppState } from '../../types'; 2 | 3 | export const selectAnyPage = (state: S): S['appContext']['page'] => 4 | state.appContext.page; 5 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/signals/page.ts: -------------------------------------------------------------------------------- 1 | import { openAnyPageAction } from 'framework/infrastructure/router/redux/actions/appContext/openPageAction'; 2 | import { historyPush, historyReplace } from 'framework/infrastructure/router/redux/actions/router'; 3 | import { commonWithSelectors, createSignal, sequence } from 'framework/infrastructure/signal'; 4 | 5 | import { AnyPage } from '../../types'; 6 | 7 | export const openPageSignal = createSignal( 8 | 'openPageSignal', 9 | (page: AnyPage, useReplace?: boolean) => 10 | sequence(openAnyPageAction(page), useReplace ? historyReplace() : historyPush()), 11 | ); 12 | 13 | /** 14 | * Useful in cases, when you need to change params of the current page 15 | */ 16 | export const patchPageSignal = createSignal( 17 | 'patchPageSignal', 18 | (patcher: (activePage: AnyPage) => AnyPage, useReplace?: boolean) => { 19 | return commonWithSelectors( 20 | { 21 | activePage: (state) => state.appContext.page, 22 | }, 23 | ({ activePage }) => { 24 | const newPage = patcher(activePage); 25 | 26 | return sequence(openAnyPageAction(newPage), useReplace ? historyReplace() : historyPush()); 27 | }, 28 | ); 29 | }, 30 | ); 31 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/store/configureStore.ts: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, legacy_createStore, Middleware, compose, StoreEnhancer, Store } from 'redux'; 2 | 3 | import { createNavigationMiddleware } from 'framework/infrastructure/router/redux/middlewares'; 4 | import { AnyAppContext, AnyAppState, AnyPage } from 'framework/infrastructure/router/types'; 5 | import { createSignalMiddleware } from 'framework/infrastructure/signal/middleware'; 6 | 7 | import { createReducer, CreateReducerOptions } from './reducer'; 8 | import { URLCompiler } from '../../compileURL'; 9 | 10 | export function configureStore>(params: { 11 | initialState: AnyAppState; 12 | middlewares: Middleware[]; 13 | enhancers: StoreEnhancer[]; 14 | compileAppURL: URLCompiler; 15 | createReducerOptions: CreateReducerOptions; 16 | }) { 17 | const { initialState, middlewares, enhancers, compileAppURL, createReducerOptions } = params; 18 | const appliedMiddlewares = applyMiddleware( 19 | createSignalMiddleware(), 20 | createNavigationMiddleware(compileAppURL), 21 | ...middlewares, 22 | ); 23 | 24 | const finalEnhancer = compose(appliedMiddlewares, ...enhancers) as StoreEnhancer; 25 | 26 | return legacy_createStore( 27 | createReducer(initialState, createReducerOptions), 28 | initialState, 29 | finalEnhancer, 30 | ) as any as Store>>; 31 | } 32 | -------------------------------------------------------------------------------- /src/framework/infrastructure/router/redux/store/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { ReactReduxContextValue } from 'react-redux'; 3 | import { UnknownAction } from 'redux'; 4 | 5 | /** 6 | * This is a mocked store for a custom redux context 7 | * A real store will be added in Provider. 8 | * Such context allows to have several individual redux stores 9 | */ 10 | export const RouterReduxContext: React.Context | null> = 11 | createContext({} as ReactReduxContextValue | null); 12 | -------------------------------------------------------------------------------- /src/framework/infrastructure/session/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import { Session } from './types'; 4 | 5 | export const defaultSession: Session = { 6 | ip: '', 7 | user: '', 8 | sid: '', 9 | userAgent: '', 10 | isIOS: false, 11 | isAndroid: false, 12 | isMobile: false, 13 | isTablet: false, 14 | }; 15 | 16 | export const SessionContext = createContext(defaultSession); 17 | -------------------------------------------------------------------------------- /src/framework/infrastructure/session/hook.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { SessionContext } from './context'; 4 | 5 | export const useSession = () => { 6 | const session = useContext(SessionContext); 7 | 8 | return session; 9 | }; 10 | -------------------------------------------------------------------------------- /src/framework/infrastructure/session/types.ts: -------------------------------------------------------------------------------- 1 | export type SearchBotName = 'google' | 'yandex' | 'bing' | 'mail'; 2 | 3 | export interface Session { 4 | ip: string; 5 | user: string; 6 | sid: string; 7 | userAgent: string; 8 | isIOS: boolean; 9 | isAndroid: boolean; 10 | isMobile: boolean; 11 | isTablet: boolean; 12 | isSearchBot?: boolean; 13 | searchBotName?: SearchBotName; 14 | } 15 | -------------------------------------------------------------------------------- /src/framework/infrastructure/signal/constants.ts: -------------------------------------------------------------------------------- 1 | export const SIGNAL_ACTION = 'INTERNAL/ACTION_SIGNAL'; 2 | export const SEQUENCE_ACTION = 'INTERNAL/ACTION_SEQUENCE'; 3 | export const PARALLEL_ACTION = 'INTERNAL/ACTION_PARALLEL'; 4 | export const WITH_SELECTORS_ACTION = 'INTERNAL/ACTION_WITH_SELECTORS'; 5 | export const NOOP_ACTION = 'INTERNAL/ACTION_NOOP'; 6 | -------------------------------------------------------------------------------- /src/framework/infrastructure/signal/types.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from 'react-redux'; 2 | import { UnknownAction } from 'redux'; 3 | 4 | export type SignalActionWithActionInPayload = UnknownAction & { 5 | payload: UnknownAction; 6 | }; 7 | 8 | export type SignalActionWithActionsInPayload = UnknownAction & { 9 | payload: UnknownAction[]; 10 | }; 11 | 12 | export type SignalActionWithSelectorsInPayload = UnknownAction & { 13 | selectors: Record>; 14 | payload: (params: Record) => UnknownAction; 15 | }; 16 | -------------------------------------------------------------------------------- /src/framework/infrastructure/signal/utils.ts: -------------------------------------------------------------------------------- 1 | import { UnknownAction, isAction } from 'redux'; 2 | 3 | import { 4 | SignalActionWithActionInPayload, 5 | SignalActionWithActionsInPayload, 6 | SignalActionWithSelectorsInPayload, 7 | } from './types'; 8 | 9 | export function isSignalActionWithActionInPayload( 10 | action: UnknownAction, 11 | ): action is SignalActionWithActionInPayload { 12 | return 'payload' in action && isAction(action.payload); 13 | } 14 | 15 | export function isSignalActionWithActionsInPayload( 16 | action: UnknownAction, 17 | ): action is SignalActionWithActionsInPayload { 18 | return 'payload' in action && Array.isArray(action.payload); 19 | } 20 | 21 | export function isSignalActionWithSelectorsInPayload( 22 | action: UnknownAction, 23 | ): action is SignalActionWithSelectorsInPayload { 24 | return 'payload' in action && 'selectors' in action; 25 | } 26 | -------------------------------------------------------------------------------- /src/framework/infrastructure/tests/dom/dt/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { expect } from 'chai'; 3 | 4 | import { dt } from '..'; 5 | 6 | describe('dt util', () => { 7 | it('react-testing-library selects by t-attribute', () => { 8 | const { getAllByTestId } = render( 9 |
10 |
11 |
12 |
13 |
, 14 | ); 15 | 16 | expect(getAllByTestId('link')).to.have.length(1); 17 | expect(getAllByTestId('other_link')).to.have.length(2); 18 | let error; 19 | 20 | try { 21 | getAllByTestId('no_link'); 22 | } catch (e) { 23 | error = e; 24 | } 25 | 26 | expect( 27 | error, 28 | 'Error should be instance of Error, cause there is no elements with no_link attr', 29 | ).to.be.instanceOf(Error); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/framework/infrastructure/tests/dom/dt/index.ts: -------------------------------------------------------------------------------- 1 | export const DATA_T_ATTRIBUTE_NAME = 'data-t'; 2 | 3 | /** 4 | * data-t — label for el for tests 5 | * Used with react-testing-library in method getByTestId and getAllByTestId 6 | * 7 | * @example 8 | *
{children}
9 | */ 10 | export function dt(label: string) { 11 | if (process.env.NODE_ENV === 'production') { 12 | return {}; 13 | } 14 | 15 | return { [DATA_T_ATTRIBUTE_NAME]: label }; 16 | } 17 | -------------------------------------------------------------------------------- /src/framework/infrastructure/tests/dom/env/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, functional/immutable-data */ 2 | 3 | const jsdom = require('jsdom'); 4 | const KEYS = ['window', 'document']; 5 | const defaultHtml = ''; 6 | 7 | module.exports = function initJSDOMEnv(html, options) { 8 | if (html === undefined) { 9 | html = defaultHtml; 10 | } 11 | 12 | if (options === undefined) { 13 | options = { 14 | pretendToBeVisual: true, 15 | }; 16 | } 17 | 18 | if ( 19 | global.navigator && 20 | global.navigator.userAgent && 21 | global.navigator.userAgent.indexOf('Node.js') > -1 && 22 | global.document && 23 | typeof global.document.destroy === 'function' 24 | ) { 25 | return global.document.destroy; 26 | } 27 | 28 | const { window } = new jsdom.JSDOM(html, options); 29 | 30 | KEYS.forEach((key) => { 31 | global[key] = window[key]; 32 | }); 33 | 34 | global.document = window.document; 35 | global.window = window; 36 | window.console = global.console; 37 | document.destroy = cleanup; 38 | 39 | function cleanup() { 40 | KEYS.forEach((key) => { 41 | delete global[key]; 42 | }); 43 | } 44 | 45 | return cleanup; 46 | }; 47 | -------------------------------------------------------------------------------- /src/framework/infrastructure/tests/dom/location/index.ts: -------------------------------------------------------------------------------- 1 | import { stub } from 'sinon'; 2 | 3 | interface MockedLocation extends Location { 4 | assign: ReturnType; 5 | reload: ReturnType; 6 | replace: ReturnType; 7 | } 8 | 9 | interface MockedWindow extends Window { 10 | location: MockedLocation; 11 | } 12 | 13 | export function mockWindowLocation(win: Window = window, href = win.location.href) { 14 | const locationMocks: Partial = { 15 | reload: stub(), 16 | assign: stub(), 17 | replace: stub(), 18 | }; 19 | 20 | return replaceLocation(href); 21 | 22 | function replaceLocation(url: string) { 23 | // @ts-ignore 24 | delete win.location; // eslint-disable-line functional/immutable-data 25 | // eslint-disable-next-line functional/immutable-data 26 | win.location = Object.assign(new URL(url), locationMocks) as any; 27 | return win as MockedWindow; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/framework/infrastructure/tests/stub/index.ts: -------------------------------------------------------------------------------- 1 | import { stub } from 'sinon'; 2 | 3 | import { colorize } from 'lib/console/colorize'; 4 | 5 | /** 6 | * Creates a sinon wrapper for any function inside any object 7 | * 8 | * Let's imagine, we have a service Entity, which is a simple object: 9 | * 10 | * @example 11 | * const createEntityService = () => ({ 12 | * getEntity: () => {}, 13 | * }) 14 | * 15 | * And we need to wrap all methods from EntityService with sinon methods for the testing purpose 16 | * @example 17 | * const createStubbedEntity = () => { 18 | * const stubMethod = createMethodStubber('entity'); 19 | * 20 | * return { 21 | * getEntity: stubMethod('getEntity'), 22 | * mocks: { 23 | * entity: { name: 'test' }, 24 | * } 25 | * } 26 | * } 27 | */ 28 | /* istanbul ignore next */ 29 | export function createMethodStubber(entityName: string) { 30 | return any>(methodName: string) => { 31 | const stubedMethod = stub, ReturnType>(); 32 | 33 | // Add fake call to prevent passing test, which use API and did not stub it 34 | stubedMethod.callsFake(() => { 35 | // eslint-disable-next-line no-console 36 | console.log('\n'); 37 | // eslint-disable-next-line no-console 38 | console.log(colorize('Method is not mocked, but used in test!', 'red')); 39 | // eslint-disable-next-line no-console 40 | console.log(`Entity: ${colorize(entityName, 'cyan')}`); 41 | // eslint-disable-next-line no-console 42 | console.log(`Method: ${colorize(methodName, 'cyan')}`); 43 | // eslint-disable-next-line no-console 44 | console.log('\n'); 45 | throw new Error(`Method "${methodName}" is not mocked, but used in test`); 46 | }); 47 | 48 | return stubedMethod; 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/framework/infrastructure/webpack/constants.ts: -------------------------------------------------------------------------------- 1 | export const ASSETS_STATS_FILE_NAME = 'stats.json'; 2 | export const PAGE_DEPENDENCIES_FILE_NAME = 'page_dependencies.json'; 3 | -------------------------------------------------------------------------------- /src/framework/public/client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Client-specific functions/constants are exported here 3 | * 4 | * You can use it on a client side only 5 | */ 6 | 7 | export { startClientApplication } from '../applications/client'; 8 | export { getClientApplicationConfig } from '../config/generator/client'; 9 | -------------------------------------------------------------------------------- /src/framework/public/constants.ts: -------------------------------------------------------------------------------- 1 | export { 2 | applicationContainerId as ApplicationContainerId, 3 | utilityRouterPath, 4 | } from 'framework/constants/application'; 5 | -------------------------------------------------------------------------------- /src/framework/public/readme.md: -------------------------------------------------------------------------------- 1 | This is a dir, from where you can import only available functions and helpers from `framework`. 2 | 3 | All other imports from 'framework' directory are forbidden 4 | -------------------------------------------------------------------------------- /src/framework/public/server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Server-specific functions/constants are exported here 3 | * 4 | * You can use it on a server side only 5 | */ 6 | 7 | export { startServer } from '../applications/server'; 8 | export { createApplicationRouteHandler } from '../applications/server/createApplicationRouteHandler'; 9 | 10 | export { 11 | buildClientApplicationConfig, 12 | buildServerApplicationConfig, 13 | buildServerConfig, 14 | } from '../config/generator/server'; 15 | 16 | export { createURLParser } from '../infrastructure/router/parseURL'; 17 | 18 | export { createCookieAPI } from '../infrastructure/platform/cookie/server'; 19 | 20 | export { type Metadata } from '../types/metadata'; 21 | 22 | export { type GetMetadata } from '../applications/server/types'; 23 | -------------------------------------------------------------------------------- /src/framework/public/styles.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Yes, these imports could be in a common.ts 3 | * But it is a small improvement of compilation speed, 4 | * cause `createStyles` is used in all .css.ts files 5 | * And these files have its own compilation pipeline. 6 | * 7 | * It would be better to compile as less code pisces as possible 8 | */ 9 | 10 | export { useStyles, createStyles } from 'framework/infrastructure/css/hook'; 11 | -------------------------------------------------------------------------------- /src/framework/public/tests.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests-specific functions/constants are exported here 3 | * 4 | * You can use it in tests only 5 | */ 6 | 7 | export { appLoggerStub } from 'framework/infrastructure/logger/stub'; 8 | export { createJsonResponse, createOkJsonResponse } from 'framework/infrastructure/request/utils/tests'; 9 | 10 | export { createMethodStubber } from 'framework/infrastructure/tests/stub'; 11 | -------------------------------------------------------------------------------- /src/framework/public/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Universal types are exported here 3 | * 4 | * You can use it on a client side and on a server side as well 5 | */ 6 | 7 | export type { BaseApplicationConfig, BaseServerConfig } from 'framework/config/types'; 8 | 9 | export type { 10 | AnyAppContext, 11 | AnyPage, 12 | URLQueryParams, 13 | Routes, 14 | ClientRouter, 15 | ServerRouter, 16 | } from 'framework/infrastructure/router/types'; 17 | 18 | export type { ParsedError, Requester } from 'framework/infrastructure/request/types'; 19 | 20 | export type { AppLogger } from 'framework/infrastructure/logger'; 21 | 22 | export type { AllowedInlineStyle } from 'framework/infrastructure/css/types'; 23 | -------------------------------------------------------------------------------- /src/lib/browser/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { isIE } from '../'; 4 | 5 | describe('Utils core', () => { 6 | describe('isIE', () => { 7 | it('True for IE11', () => { 8 | const UA = 'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko'; 9 | expect(isIE(UA)).to.be.true; 10 | }); 11 | 12 | it('True for IE10', () => { 13 | const UA = 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)'; 14 | expect(isIE(UA)).to.be.true; 15 | }); 16 | 17 | it('True for IE9', () => { 18 | const UA = 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)'; 19 | expect(isIE(UA)).to.be.true; 20 | }); 21 | 22 | it('False for Edge', () => { 23 | const UA = 24 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10240'; 25 | expect(isIE(UA)).to.be.false; 26 | }); 27 | 28 | it('False for Chrome', () => { 29 | const UA = 30 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36'; 31 | expect(isIE(UA)).to.be.false; 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/lib/browser/index.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from 'react'; 2 | const isClient = 3 | typeof window !== 'undefined' && 4 | typeof window.document !== 'undefined' && 5 | typeof window.document.createElement !== 'undefined'; 6 | export const isServer = !isClient; 7 | export const isTest = process.env.NODE_ENV === 'test'; 8 | 9 | export function isIE(UA: string) { 10 | const lowerCasedUA = UA.toLowerCase(); 11 | 12 | return lowerCasedUA.indexOf('msie') !== -1 || lowerCasedUA.indexOf('trident') !== -1; 13 | } 14 | 15 | /* istanbul ignore next */ 16 | export function isNewTabOpenRequest(e: MouseEvent) { 17 | return e.metaKey || e.ctrlKey; 18 | } 19 | 20 | export const getViewportSize = () => { 21 | if (isServer) { 22 | return { 23 | width: 0, 24 | height: 0, 25 | }; 26 | } 27 | 28 | const body = document.body; 29 | const html = document.documentElement; 30 | 31 | return { 32 | width: Math.max(body.offsetWidth, html.clientWidth, html.offsetWidth), 33 | height: Math.max(body.offsetHeight, html.clientHeight, html.offsetHeight), 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/lib/console/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { colorize } from '../colorize'; 4 | 5 | describe('colorize lib', () => { 6 | it('Return blue text, if passed blue color', () => { 7 | expect(colorize('text', 'blue')).to.eq('\x1b[1m\x1b[34mtext\x1b[0m'); 8 | }); 9 | 10 | it('Return cyan text, if passed cyan color', () => { 11 | expect(colorize('text', 'cyan')).to.eq('\x1b[1m\x1b[36mtext\x1b[0m'); 12 | }); 13 | 14 | it('Return green text, if passed green color', () => { 15 | expect(colorize('text', 'green')).to.eq('\x1b[1m\x1b[32mtext\x1b[0m'); 16 | }); 17 | 18 | it('Return red text, if passed red color', () => { 19 | expect(colorize('text', 'red')).to.eq('\x1b[1m\x1b[31mtext\x1b[0m'); 20 | }); 21 | 22 | it('Return white text, if color is not passed or passed white color', () => { 23 | expect(colorize('text')).to.eq('\x1b[1m\x1b[37mtext\x1b[0m'); 24 | expect(colorize('text', 'white')).to.eq('\x1b[1m\x1b[37mtext\x1b[0m'); 25 | }); 26 | 27 | it('Return yellow text, if passed yellow color', () => { 28 | expect(colorize('text', 'yellow')).to.eq('\x1b[1m\x1b[33mtext\x1b[0m'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/lib/console/colorize.ts: -------------------------------------------------------------------------------- 1 | type Color = 'red' | 'green' | 'cyan' | 'yellow' | 'blue' | 'white'; 2 | 3 | export function colorize(msg: string, color?: Color) { 4 | if (process.env.APP_ENV === 'client') { 5 | return console.log(msg, { 6 | color, 7 | }); 8 | } 9 | 10 | const selectedColor = getColor(color); 11 | const reset = '\x1b[0m'; 12 | 13 | return `\x1b[1m${selectedColor}${msg}${reset}`; 14 | } 15 | 16 | function getColor(color?: Color) { 17 | // white by default 18 | switch (color) { 19 | case 'blue': 20 | return '\x1b[34m'; 21 | case 'cyan': 22 | return '\x1b[36m'; 23 | case 'green': 24 | return '\x1b[32m'; 25 | case 'red': 26 | return '\x1b[31m'; 27 | case 'yellow': 28 | return '\x1b[33m'; 29 | case 'white': 30 | default: 31 | return '\x1b[37m'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/console/devConsole.ts: -------------------------------------------------------------------------------- 1 | type LogParams = Parameters; 2 | export const devConsoleLog = (...logParams: LogParams) => { 3 | if (process.env.NODE_ENV === 'development') { 4 | // eslint-disable-next-line no-console 5 | console.log(...logParams); 6 | } 7 | }; 8 | 9 | type ErrorParams = Parameters; 10 | export const devConsoleError = (...logParams: ErrorParams) => { 11 | if (process.env.NODE_ENV === 'development') { 12 | // eslint-disable-next-line no-console 13 | console.error(...logParams); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/cookies/client.ts: -------------------------------------------------------------------------------- 1 | import clientCookie from 'js-cookie'; 2 | 3 | import { Cookie } from './types'; 4 | 5 | export const createCookieAPI = (): Cookie => ({ 6 | set(name: string, value: string, options?: clientCookie.CookieAttributes) { 7 | if (options && 'maxAge' in options && typeof options['maxAge'] !== 'undefined') { 8 | // eslint-disable-next-line functional/immutable-data 9 | options['Max-Age'] = (options['maxAge'] as any).toString(); 10 | // eslint-disable-next-line functional/immutable-data 11 | delete options['maxAge']; 12 | } 13 | 14 | clientCookie.set(name, value, options); 15 | }, 16 | get(name: string) { 17 | return clientCookie.get(name); 18 | }, 19 | 20 | delete(name: string, options?: clientCookie.CookieAttributes) { 21 | clientCookie.remove(name, options); 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/lib/cookies/server.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, CookieOptions } from 'express'; 2 | 3 | import { Cookie } from './types'; 4 | 5 | export const createCookieAPI = (req: Request, res: Response): Cookie => ({ 6 | set(name: string, value: string, options?: CookieOptions) { 7 | let opts = options || {}; 8 | 9 | // Express takes maxAge in miliseconds 10 | if (options?.maxAge) { 11 | opts = { 12 | ...opts, 13 | maxAge: Math.round(options.maxAge * 1000), 14 | }; 15 | } 16 | 17 | res.cookie(name, value, opts); 18 | }, 19 | get(name: string) { 20 | return req.cookies[name]; 21 | }, 22 | 23 | delete(name: string, options?: CookieOptions) { 24 | return res.clearCookie(name, options); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /src/lib/cookies/types.ts: -------------------------------------------------------------------------------- 1 | import { CookieOptions } from 'express'; 2 | import { CookieAttributes } from 'js-cookie'; 3 | 4 | export interface Cookie { 5 | set(name: string, value: string, options?: CookieOptions | CookieAttributes): void; 6 | get(name: string): string | undefined; 7 | delete(name: string, options?: CookieOptions | CookieAttributes): void; 8 | } 9 | 10 | export interface NamedCookie extends CookieOptions { 11 | name: string; 12 | } 13 | 14 | export interface NamedCookieConfig { 15 | name: string; 16 | maxAge?: number; 17 | signed?: boolean; 18 | expiresPeriod?: number; 19 | httpOnly?: boolean; 20 | path?: string; 21 | domain?: string; 22 | secure?: boolean | 'auto'; 23 | encode?: (val: string) => void; 24 | sameSite?: boolean | string; 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/hooks/useInvalidateQuery.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InvalidateOptions, 3 | InvalidateQueryFilters, 4 | QueryKey, 5 | useQueryClient, 6 | } from '@tanstack/react-query'; 7 | import { useCallback } from 'react'; 8 | 9 | export const useInvalidateQuery = () => { 10 | const queryClient = useQueryClient(); 11 | 12 | return useCallback( 13 | ( 14 | invalidateFilters: InvalidateQueryFilters & { queryKey: QueryKey }, 15 | invalidateOptions?: InvalidateOptions, 16 | ) => { 17 | return queryClient.invalidateQueries(invalidateFilters, invalidateOptions); 18 | }, 19 | [queryClient], 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/lib/hooks/useOnDidMount.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | /** 4 | * Exec callback asynchronously after didMount of a component 5 | */ 6 | export const useOnDidMount = (callback: () => void) => { 7 | const mutableIsMounted = useRef(false); 8 | 9 | useEffect(() => { 10 | if (!mutableIsMounted.current) { 11 | mutableIsMounted.current = true; 12 | callback(); 13 | } 14 | }, [callback]); 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/hooks/useRefetchQuery.ts: -------------------------------------------------------------------------------- 1 | import { QueryKey, RefetchOptions, RefetchQueryFilters, useQueryClient } from '@tanstack/react-query'; 2 | import { useCallback } from 'react'; 3 | 4 | export const useRefetchQuery = () => { 5 | const queryClient = useQueryClient(); 6 | 7 | return useCallback( 8 | (refetchFilters: RefetchQueryFilters & { queryKey: QueryKey }, refetchOptions?: RefetchOptions) => { 9 | return queryClient.refetchQueries(refetchFilters, refetchOptions); 10 | }, 11 | [queryClient], 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/lodash/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore next */ 2 | export function noopFunc() { 3 | /** 4 | * This is just noop function 5 | */ 6 | } 7 | 8 | export function isObject(val: unknown) { 9 | return val !== null && typeof val === 'object' && Array.isArray(val) === false; 10 | } 11 | 12 | /** 13 | * 14 | * @param object 15 | * @param pathString only string with . 16 | * @param def 17 | */ 18 | export function get(object: { [i: string]: any }, pathString: string, def?: any) { 19 | const pathList = pathString.split('.'); 20 | return ( 21 | pathList.reduce( 22 | (result, path) => (isObject(result) || Array.isArray(result) ? result[path] : undefined), 23 | object, 24 | ) || def 25 | ); 26 | } 27 | 28 | /** 29 | * Returns typed Array of keys of received object 30 | * @param {T} obj 31 | * @returns {(keyof T)[]} 32 | */ 33 | export function keysOf>(obj: T) { 34 | if (typeof obj !== 'object') { 35 | return []; 36 | } 37 | return Object.keys(obj) as Array; 38 | } 39 | 40 | // eslint-disable-next-line @typescript-eslint/ban-types 41 | export function debounce(func: T, timeout: number): T { 42 | let timer: ReturnType | undefined; 43 | 44 | const debounced = (...args: any) => { 45 | if (timer) { 46 | clearTimeout(timer); 47 | } 48 | 49 | timer = setTimeout(() => { 50 | func(...args); 51 | }, timeout); 52 | }; 53 | 54 | return debounced as any; 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/queue/index.ts: -------------------------------------------------------------------------------- 1 | type Action

= (payload: P) => Promise; 2 | interface QueueParams

{ 3 | action: Action

; 4 | timeout?: number; 5 | onErrorAction?: (error: Error, payload: P) => void; 6 | } 7 | 8 | export class Queue

{ 9 | private mutableQueue: P[] = []; 10 | private action: Action

; 11 | private timeout: number | undefined; 12 | private onErrorAction?: (error: Error, payload: P) => void; 13 | private mutableIsBusy = false; 14 | 15 | constructor(params: QueueParams

) { 16 | this.action = params.action; 17 | this.timeout = params.timeout; 18 | this.onErrorAction = params.onErrorAction; 19 | } 20 | 21 | public addToQueue(jobPayload: P) { 22 | this.mutableQueue.push(jobPayload); 23 | this.startQueue(); 24 | } 25 | 26 | private startQueue() { 27 | this.doNextJob(); 28 | } 29 | 30 | private doNextJob() { 31 | if (this.mutableIsBusy) { 32 | return; 33 | } 34 | 35 | this.mutableIsBusy = true; 36 | 37 | const nextJobPayload = this.mutableQueue.shift(); 38 | 39 | if (!nextJobPayload) { 40 | this.mutableIsBusy = false; 41 | return; 42 | } 43 | 44 | if (!this.timeout) { 45 | this.doWork(nextJobPayload); 46 | return; 47 | } 48 | 49 | setTimeout(() => { 50 | this.doWork(nextJobPayload); 51 | }, this.timeout); 52 | } 53 | 54 | private doWork(jobPayload: P) { 55 | this.action(jobPayload) 56 | .then(() => { 57 | this.mutableIsBusy = false; 58 | this.doNextJob(); 59 | }) 60 | .catch((rawError) => { 61 | if (this.onErrorAction) { 62 | const error = rawError instanceof Error ? rawError : new Error(rawError); 63 | this.onErrorAction(error, jobPayload); 64 | } 65 | 66 | this.mutableIsBusy = false; 67 | this.doNextJob(); 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/lib/tests/wait.ts: -------------------------------------------------------------------------------- 1 | import { SinonFakeTimers } from 'sinon'; 2 | 3 | export const waitForNextTickWithMockedTimers = async (clock: SinonFakeTimers, ms: number) => { 4 | clock.tick(ms); 5 | await new Promise((resolve) => { 6 | resolve(); 7 | }); 8 | 'runMicrotasks' in clock && clock.runMicrotasks(); 9 | }; 10 | 11 | export const waitForResolve = (callback: () => any) => Promise.resolve().then(callback); 12 | -------------------------------------------------------------------------------- /tools/removeOldFiles.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | main(); 5 | 6 | function main() { 7 | console.log('Cache date: ', __filename); 8 | const pathToFiles = path.join(process.cwd(), 'build', 'public'); 9 | const readDirResult = fs.readdirSync(pathToFiles); 10 | const currentDateInMs = +new Date(); 11 | 12 | // 90 days 13 | const diff = 1000 * 60 * 60 * 24 * 90; 14 | const mutableResult = []; 15 | const mutableFreshFiles = []; 16 | 17 | readDirResult.forEach((file) => { 18 | const pathToFile = path.join(pathToFiles, file); 19 | const statResult = fs.statSync(pathToFile); 20 | 21 | if (currentDateInMs - statResult.atimeMs > diff) { 22 | fs.unlinkSync(pathToFile); 23 | mutableResult.push(file); 24 | } else { 25 | mutableFreshFiles.push(pathToFile); 26 | } 27 | }); 28 | 29 | console.log('------------------------------'); 30 | console.log('Old files removing result: '); 31 | if (!mutableResult.length) { 32 | console.log('No old files!'); 33 | } else { 34 | mutableResult.forEach((r) => { 35 | console.log(r); 36 | }); 37 | } 38 | console.log('------------------------------'); 39 | console.log('------------------------------'); 40 | console.log('Fresh files: '); 41 | mutableFreshFiles.forEach((r) => { 42 | console.log(r); 43 | }); 44 | console.log('------------------------------'); 45 | } 46 | -------------------------------------------------------------------------------- /tools/setupChaiDomAsserions.js: -------------------------------------------------------------------------------- 1 | // Setup chai to work with react-testing-library assertions/expect 2 | const chai = require('chai'); 3 | chai.use(require('chai-dom')); 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "*": [ 6 | "src/*" 7 | ] 8 | }, 9 | "esModuleInterop": true, 10 | "declaration": false, 11 | "declarationMap": false, 12 | "moduleResolution": "node", 13 | "module": "commonjs", 14 | "incremental": true, 15 | "inlineSourceMap": true, 16 | "inlineSources": true, 17 | "isolatedModules": true, 18 | "alwaysStrict": true, 19 | "pretty": false, 20 | "jsx": "react-jsx", 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "target": "es6", 24 | "noEmitHelpers": true, 25 | "importHelpers": true, 26 | "noUncheckedIndexedAccess": true, 27 | "noUnusedLocals": true, 28 | "noUnusedParameters": true, 29 | "strictPropertyInitialization": false, 30 | "skipLibCheck": true, 31 | "lib": [ 32 | "es6", 33 | "es2016", 34 | "es2017", 35 | "es2018", 36 | "dom" 37 | ] 38 | }, 39 | "include": ["src/**/*", "webpack/**/*", "project.d.ts"], 40 | "exclude": [ 41 | "node_modules" 42 | ], 43 | "compileOnSave": false 44 | } 45 | -------------------------------------------------------------------------------- /webpack/universal.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import webpack from 'webpack'; 4 | 5 | export const universalConfig: webpack.Configuration = { 6 | resolve: { 7 | modules: ['../node_modules', path.resolve(__dirname, '..', 'src')], 8 | alias: { 9 | lib: path.resolve(__dirname, '..', 'src/lib'), 10 | framework: path.resolve(__dirname, '..', 'src/framework'), 11 | }, 12 | extensions: ['.ts', '.tsx', '.js'], 13 | }, 14 | 15 | plugins: [ 16 | new webpack.DefinePlugin({ 17 | 'process.env.APP_VERSION': JSON.stringify(process.env.APP_VERSION), 18 | 'process.env.SERVER_UTILITY_ROUTER_PATH': JSON.stringify(process.env.SERVER_UTILITY_ROUTER_PATH), 19 | 'process.env.APPLICATION_CONTAINER_ID': JSON.stringify(process.env.APPLICATION_CONTAINER_ID), 20 | }), 21 | 22 | // It is necessary, cause esbuild-loader does not support React's new JSX transform 23 | // So, we can not write something like this: import React from 'react'; while using raw esbuild-loader 24 | // To fix the problem we can provide React via webpack.ProvidePlugin 25 | // https://github.com/privatenumber/esbuild-loader/issues/184 26 | new webpack.ProvidePlugin({ 27 | React: 'react', 28 | }), 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /webpack/utils/isProduction.ts: -------------------------------------------------------------------------------- 1 | export function isProduction() { 2 | return process.env.NODE_ENV === 'production'; 3 | } 4 | -------------------------------------------------------------------------------- /webpack/utils/merge.ts: -------------------------------------------------------------------------------- 1 | const mergeWith = require('lodash.mergewith'); 2 | 3 | function customizer(objValue: any, srcValue: any) { 4 | if (Array.isArray(objValue)) { 5 | return objValue.concat(srcValue); 6 | } 7 | } 8 | 9 | /** 10 | * Deep merge two objects. 11 | * @param mutableTarget 12 | * @param ...sources 13 | */ 14 | export function merge(mutableTarget: any, ...sources: any): any { 15 | return mergeWith(mutableTarget, ...sources, customizer); 16 | } 17 | -------------------------------------------------------------------------------- /webpack/utils/pino.ts: -------------------------------------------------------------------------------- 1 | import { BannerPlugin, EntryObject } from 'webpack'; 2 | 3 | /** 4 | * This plugin and entries allow to use of pino v7 with webpack generated bundle files. 5 | * More details here: https://www.npmjs.com/package/pino-webpack-plugin 6 | */ 7 | 8 | export const pinoEntries: EntryObject = { 9 | 'thread-stream-worker': { 10 | import: './node_modules/thread-stream/lib/worker.js', 11 | library: { 12 | type: 'commonjs2', 13 | }, 14 | }, 15 | 'pino-file': { 16 | import: './node_modules/pino/file.js', 17 | library: { 18 | type: 'commonjs2', 19 | }, 20 | }, 21 | 'pino-worker': { 22 | import: './node_modules/pino/lib/worker.js', 23 | library: { 24 | type: 'commonjs2', 25 | }, 26 | }, 27 | 'pino-pipeline-worker': { 28 | import: './node_modules/pino/lib/worker-pipeline.js', 29 | library: { 30 | type: 'commonjs2', 31 | }, 32 | }, 33 | 'pino-pretty': { 34 | import: './node_modules/pino-pretty/index.js', 35 | library: { 36 | type: 'commonjs2', 37 | }, 38 | }, 39 | }; 40 | 41 | export const pinoBannerPlugin = new BannerPlugin({ 42 | banner: ` 43 | /* Start of pino-webpack-plugin additions */ 44 | function pinoWebpackAbsolutePath(p) { 45 | return require('path').join(__dirname, p) 46 | } 47 | globalThis.__bundlerPathsOverrides = {'pino/file': pinoWebpackAbsolutePath('./pino-file.js'),'pino-worker': pinoWebpackAbsolutePath('./pino-worker.js'),'pino-pipeline-worker': pinoWebpackAbsolutePath('./pino-pipeline-worker.js'),'pino-pretty': pinoWebpackAbsolutePath('./pino-pretty.js'),'thread-stream-worker': pinoWebpackAbsolutePath('./thread-stream-worker.js')}; 48 | /* End of pino-webpack-bundler additions */ 49 | `, 50 | raw: true, 51 | entryOnly: true, 52 | }); 53 | --------------------------------------------------------------------------------