Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSR prod и dev режимы #59

Merged
merged 14 commits into from
Apr 11, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
CLIENT_PORT=3000
SERVER_PORT=3001
SERVER_PORT=5000
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ jobs:
run: yarn
- name: Initialize
run: yarn lerna bootstrap
- name: Build client
run: yarn build --scope=client
- name: Build ssr
run: yarn build:ssr
- name: Lint
run: yarn lint
- name: Test
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
"scripts": {
"bootstrap": "yarn && node init.js && lerna clean && yarn && lerna bootstrap",
"build": "lerna run build",
"build:ssr": "lerna run build:ssr",
"dev:client": "lerna run dev --scope=client",
"dev:server": "lerna run dev --scope=server",
"dev": "lerna run dev",
"test": "lerna run test",
"lint": "lerna run lint",
"format": "lerna run format",
"preview": "lerna run preview"
"preview": "lerna run preview",
"start": "lerna run start"
},
"license": "MIT",
"workspaces": [
Expand Down
1 change: 1 addition & 0 deletions packages/client/.env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:5000/api/v2
2 changes: 2 additions & 0 deletions packages/client/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
node_modules
9 changes: 6 additions & 3 deletions packages/client/index.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="{{__THEME__}}">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fancy Colors</title>
<script>
window.__INITIAL_STATE__ = <!--store-data-->;
</script>
</head>
<body>
<div id="root"></div>
<div id="root"><!--ssr-outlet--></div>
<div id="modals"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/entry-client.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/client/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @type {import('jest').Config} */
import dotenv from 'dotenv';
dotenv.config();

/** @type {import('jest').Config} */
export default {
preset: 'ts-jest',
testEnvironment: 'jsdom',
Expand Down
27 changes: 21 additions & 6 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,22 @@
"name": "client",
"version": "0.0.0",
"type": "module",
"main": "./dist/server/assets/js/entry-server.js",
"exports": {
".": {
"import": "./dist/server/assets/js/entry-server.js",
"types": "./dist/server/assets/js/entry-server.d.ts"
},
"./index.html": "./dist/client/index.html",
"./dev/index.html": "./index.html",
"./dev/entry-server": "./src/entry-server.tsx",
"./package.json": "./package.json"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "vite build --outDir dist/client && yarn build:types",
"build:types": "tsc --p tsconfig.prod.json",
"build:ssr": "vite build --outDir dist/server --ssr ./src/entry-server.tsx && yarn build:types",
"preview": "vite preview --host",
"lint": "run-p lint:*",
"lint:eslint": "eslint \"src/**/*.{ts,tsx}\" --cache --cache-location ./node_modules/.cache/eslint",
Expand All @@ -20,23 +33,25 @@
"node": "18.x"
},
"dependencies": {
"@reduxjs/toolkit": "1.9.3",
"@reduxjs/toolkit": "2.0.0-alpha.1",
"classnames": "2.3.2",
"colorjs.io": "0.4.3",
"dotenv": "16.0.2",
"js-cookie": "3.0.1",
"qs": "6.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.43.2",
"react-redux": "8.0.5",
"react-router-dom": "6.8.1",
"swiper": "9.1.0",
"colorjs.io": "0.4.3"
"react-router-dom": "6.10.0",
"swiper": "9.1.0"
},
"devDependencies": {
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"@types/jest": "29.4.0",
"@types/js-cookie": "3.0.3",
"@types/react": "18.0.18",
"@types/react-dom": "18.0.11",
"@vitejs/plugin-react": "3.1.0",
Expand All @@ -62,7 +77,7 @@
"ts-jest": "29.0.5",
"ts-jest-mock-import-meta": "1.0.0",
"typescript": "4.9.5",
"vite": "4.1.4",
"vite": "4.2.1",
"vite-plugin-svgr": "2.4.0",
"whatwg-fetch": "3.6.2"
},
Expand Down
6 changes: 4 additions & 2 deletions packages/client/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const URLS = [
'/game/village',
'/index.html',
'/assets/css/index.css',
'/assets/js/main.js',
'/assets/js/entry-server.js',
'/assets/png/bg-image.png',
'/assets/woff2/MontserratAlternates-Bold.woff2',
'/assets/woff2/MontserratAlternates-Regular.woff2',
Expand Down Expand Up @@ -59,13 +59,15 @@ self.addEventListener('fetch', event => {
function tryNetwork (request, timeout) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(reject, timeout);
// NOTE: https://github.com/w3c/ServiceWorker/issues/693
const allowedMethods = ['GET', 'HEAD'];

fetch(request).then(response => {
clearTimeout(timeoutId);
const responseClone = response.clone();

caches.open(CACHE_NAME).then(cache => {
if (request.url.match('^(http|https)://')) {
if (request.url.match('^(http|https)://') && allowedMethods.includes(request.method)) {
cache.put(request, responseClone);
}
})
Expand Down
12 changes: 10 additions & 2 deletions packages/client/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,16 @@ export class AuthApi extends BaseApi {
});
}

me() {
return this.http.get<UserDTO | APIError>('/user');
me(request?: Request) {
const headers: Record<string, string> = {};

if (import.meta.env.SSR && request) {
headers.cookie = request.headers.get('cookie') as string;
}

return this.http.get<UserDTO | APIError>('/user', {
headers,
});
}

logout() {
Expand Down
77 changes: 25 additions & 52 deletions packages/client/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,29 @@
import { AuthLayout, getCurrentUser } from 'components/auth-layout';
import {
Route,
defer,
createBrowserRouter,
createRoutesFromElements,
RouterProvider,
} from 'react-router-dom';
import { ProtectedRoutes } from 'utils/protected-routes';
import { Profile } from 'pages/profile';
import { MainPage } from 'pages/main';
import { HowToModal } from 'components/how-to-modal';
import { GamePage } from 'pages/game';
import { RouterPaths } from './app.types';
import { LoginPage, RegisterPage } from 'pages/auth';
import { MainLayout } from 'components/main-layout';
import { Error404, Error500 } from 'pages/error';
import { Leaderboard } from 'pages/leaderboard';
import { ForumThread } from 'pages/forum-thread';
import { NewThreadModal } from 'components/modal-new-thread';
import { Forum } from 'pages/forum';
import { ThemeProvider, Theme } from 'components/hooks/use-theme';
import React from 'react';
import { Provider as StoreProvider } from 'react-redux';
import type { AppStore, InitialState } from './store';
import './styles/index.pcss';

const router = createBrowserRouter(
createRoutesFromElements(
<Route
element={<AuthLayout />}
loader={() => defer({ userPromise: getCurrentUser() })}
>
<Route path={RouterPaths.REGISTER} element={<RegisterPage />} />
<Route path={RouterPaths.LOGIN} element={<LoginPage />} />
<Route element={<MainLayout />}>
<Route path={RouterPaths.MAIN} element={<MainPage />}>
<Route path={RouterPaths.HOW_TO} element={<HowToModal />} />
</Route>
<Route path={RouterPaths.LEADERBOARD} element={<Leaderboard />} />
<Route path={RouterPaths.FORUM} element={<Forum />}>
<Route path={RouterPaths.NEW_THREAD} element={<NewThreadModal />} />
</Route>
<Route path={`${RouterPaths.FORUM}/:id`} element={<ForumThread />} />
<Route element={<ProtectedRoutes />}>
<Route path={RouterPaths.PROFILE} element={<Profile />} />
<Route path={`${RouterPaths.GAME}/:id`} element={<GamePage />} />
</Route>
</Route>
<Route path={RouterPaths.ERROR_500} element={<Error500 />} />
<Route path={RouterPaths.ERROR_404} element={<Error404 />} />
<Route path="*" element={<Error404 />} />
</Route>
)
);

function App() {
return <RouterProvider router={router} />;
function App({
children,
theme = Theme.LIGHT,
store,
initialState,
}: {
children: React.ReactNode;
theme?: string;
store: AppStore;
initialState?: InitialState;
}) {
return (
<React.StrictMode>
<ThemeProvider initialValue={theme}>
<StoreProvider store={store} serverState={initialState}>
{children}
</StoreProvider>
</ThemeProvider>
</React.StrictMode>
);
}

export default App;
9 changes: 9 additions & 0 deletions packages/client/src/client.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
declare const __SERVER_PORT__: number;

declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/naming-convention
__INITIAL_STATE__?: string;
}
}

export {};
21 changes: 6 additions & 15 deletions packages/client/src/components/auth-layout/auth-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { authApi } from 'api/auth';
import { AuthProvider } from 'components/hooks/use-auth';
import { Suspense } from 'react';
import { Await, useLoaderData, useOutlet } from 'react-router-dom';
import { useLoaderData, useOutlet } from 'react-router-dom';
import { transformUser } from 'utils/api-transformers';
import { hasApiError } from 'utils/has-api-error';

export async function getCurrentUser() {
export async function getCurrentUser(request: Request) {
try {
const response = await authApi.me();
const response = await authApi.me(request);

if (!response || hasApiError(response)) {
return null;
Expand All @@ -19,21 +18,13 @@ export async function getCurrentUser() {
}
}

const LoadingIndicator = () => <div>Загрузка...</div>;

type LoaderDataResponse = {
userPromise: ReturnType<typeof getCurrentUser>;
user: Awaited<ReturnType<typeof getCurrentUser>>;
};

export const AuthLayout = () => {
const outlet = useOutlet();
const { userPromise } = useLoaderData() as LoaderDataResponse;
const { user } = useLoaderData() as LoaderDataResponse;

return (
<Suspense fallback={<LoadingIndicator />}>
<Await resolve={userPromise}>
{(user) => <AuthProvider userData={user}>{outlet}</AuthProvider>}
</Await>
</Suspense>
);
return <AuthProvider userData={user}>{outlet}</AuthProvider>;
};
13 changes: 13 additions & 0 deletions packages/client/src/components/client-only/client-only.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useState, useEffect } from 'react';

export const ClientOnly = ({ children }: { children: React.ReactNode }) => {
const [hasMounted, setHasMounted] = useState(false);

useEffect(() => {
setHasMounted(true);
}, []);

if (!hasMounted) return null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А этот код разве не отрабатывает раньше, чем useEffect? Это метод работает корректно весь?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Отрабатывает раньше, в этом и задумка. Это обёртка для компонентов, которые не должны рендериться на сервере.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

И как он узнает, что он не на сервере, если у него всегда hasMounted === false? На клиенте он так же получит false, я не вижу никаких других зависимостей в компоненте, чтобы дойти до рендера компонентов

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

На клиенте сработает useEffect хук после первого монтирования, hasMounted станет true (7 строчка), компонент обновится и отрендерит children.
На сервере компонент не смонтируется и вернёт null.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

оки


return <>{children}</>;
};
1 change: 1 addition & 0 deletions packages/client/src/components/client-only/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ClientOnly } from './client-only';
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useAppDispatch, useAppSelector } from 'components/hooks';
import { resetCompletedGame } from 'src/services/game-slice';
import styles from './game-view.module.pcss';
import cn from 'classnames';
import { usePatternImage } from './utils/use-pattern-image';

type Props = {
data: GameData;
Expand All @@ -23,6 +24,7 @@ export const GameViewCompleted: FC<Props> = ({ data }) => {

const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);

const patternImage = usePatternImage();
const canvasRef = useRef<HTMLCanvasElement>(null);
const resizableRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -58,8 +60,8 @@ export const GameViewCompleted: FC<Props> = ({ data }) => {

useEffect(() => {
if (!ctx) return;
drawHistory(ctx, data, movesHistory);
}, [ctx, data, movesHistory]);
drawHistory(ctx, data, movesHistory, patternImage);
}, [ctx, data, movesHistory, patternImage]);

const GameEndMessage = () => {
return (
Expand Down
Loading