From 53ed9a1a38d49bbe69a97abb02b36377c59dbd9b Mon Sep 17 00:00:00 2001 From: aube-dev Date: Sat, 14 Sep 2024 17:17:34 +0900 Subject: [PATCH] feat: add api base code (incomplete) --- .eslintrc.cjs | 50 +++++ .storybook/preview.tsx | 2 +- app/apis/auth.ts | 22 ++ .../KakaoLoginButton/KakaoLoginButton.tsx | 10 +- app/contexts/EnvContext.tsx | 2 +- app/hooks/useEnv.tsx | 2 +- app/hooks/useErrorToast.ts | 16 ++ app/hooks/useTypedFetcher.ts | 37 ++++ app/root.css.ts | 60 +++++- app/root.tsx | 47 ++++- app/routes/oauth.kakao.tsx | 20 +- functions/[[path]].ts | 7 +- package.json | 5 +- pnpm-lock.yaml | 71 ++++++- public/loading-lottie.json | 1 + server/README.md | 67 ------ server/constants/api.ts | 124 +++++++++-- server/constants/env.ts | 12 +- server/constants/types/api.ts | 25 --- server/index.ts | 27 +++ .../remixCloudflareDevProxyVitePlugin.ts | 193 ------------------ server/types/api.ts | 76 +++++++ server/{constants => }/types/auth.ts | 2 +- server/types/env.ts | 12 ++ server/utils/api.ts | 169 ++++++++------- server/utils/auth.ts | 61 ++++-- server/utils/cloudflare.ts | 62 ++++-- server/vite/index.ts | 1 + server/vite/invariant.ts | 18 ++ server/vite/nodeAdapter.ts | 83 ++++++++ .../vite/remixCloudflareDevProxyVitePlugin.ts | 95 +++++++++ tsconfig.json | 2 +- vite.config.ts | 2 +- 33 files changed, 931 insertions(+), 452 deletions(-) create mode 100644 app/apis/auth.ts create mode 100644 app/hooks/useErrorToast.ts create mode 100644 app/hooks/useTypedFetcher.ts create mode 100644 public/loading-lottie.json delete mode 100644 server/README.md delete mode 100644 server/constants/types/api.ts create mode 100644 server/index.ts delete mode 100644 server/plugins/remixCloudflareDevProxyVitePlugin.ts create mode 100644 server/types/api.ts rename server/{constants => }/types/auth.ts (85%) create mode 100644 server/types/env.ts create mode 100644 server/vite/index.ts create mode 100644 server/vite/invariant.ts create mode 100644 server/vite/nodeAdapter.ts create mode 100644 server/vite/remixCloudflareDevProxyVitePlugin.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 314b8b4..e268a3c 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -109,6 +109,12 @@ module.exports = { ], format: ['PascalCase'], }, + { + selector: 'typeParameter', + format: ['PascalCase'], + prefix: ['_'], + filter: '^_', + }, { selector: ['enumMember'], format: ['UPPER_CASE'], @@ -117,6 +123,12 @@ module.exports = { selector: ['objectLiteralProperty'], format: null, }, + { + selector: 'variable', + format: ['camelCase'], + prefix: ['api_', 'unstable_'], + filter: '(^api_|^unstable_)', + }, ], }, }, @@ -128,6 +140,24 @@ module.exports = { node: true, }, }, + + { + files: ['./server/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + // https://github.com/vitejs/vite/issues/10063 + group: ['@/*', '@server/*', 'app/*', 'server/*'], + message: 'server 디렉토리 안에서는 상대 경로를 사용해 주세요.', + }, + ], + }, + ], + }, + }, ], rules: { @@ -142,5 +172,25 @@ module.exports = { ], 'arrow-body-style': ['warn', 'as-needed'], 'prettier/prettier': 'warn', + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['..*'], + message: '다른 경로에 있는 모듈은 절대 경로로 불러와 주세요.', + }, + { + group: ['app/*', 'server/*'], + message: '`@/`, `@server` 등 올바른 절대 경로를 사용해 주세요.', + }, + { + group: ['@remix-run/react'], + importNames: ['useFetcher'], + message: '`@/hooks/useTypedFetcher`를 사용해 주세요.', + }, + ], + }, + ], }, }; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index b98f735..1939e66 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,6 +1,6 @@ import { createRemixStub } from '@remix-run/testing'; import type { Preview } from '@storybook/react'; -import '../app/root.css'; +import '@/root.css'; const preview: Preview = { parameters: { diff --git a/app/apis/auth.ts b/app/apis/auth.ts new file mode 100644 index 0000000..0606980 --- /dev/null +++ b/app/apis/auth.ts @@ -0,0 +1,22 @@ +import { Api } from '@server'; + +export const api_loginWithKakao = new Api< + { code: string; state: string }, + { + userId: number; + accessToken: string; + accessTokenExpiresAt: string; + refreshToken: string; + refreshTokenExpiresAt: string; + } +>({ + method: 'POST', + endpoint: '/login/oauth2/code/kakao', + needToLogin: false, + request: (variables) => ({ + queryParams: { + code: variables.code, + state: variables.state, + }, + }), +}); diff --git a/app/components/KakaoLoginButton/KakaoLoginButton.tsx b/app/components/KakaoLoginButton/KakaoLoginButton.tsx index 9ca08d5..6191d99 100644 --- a/app/components/KakaoLoginButton/KakaoLoginButton.tsx +++ b/app/components/KakaoLoginButton/KakaoLoginButton.tsx @@ -1,18 +1,18 @@ import React, { useEffect, useState } from 'react'; import useEnv from '@/hooks/useEnv'; -import { getUUID } from '@/utils/random'; const KakaoLoginButton: React.FC = () => { const env = useEnv(); const [kakaoAuthUrl, setKakaoAuthUrl] = useState(''); useEffect(() => { - const userUUID = getUUID(); - const redirectUri = window.location.origin + '/oauth/kakao'; + const redirectUri = + window.location.origin.replace('localhost', '127.0.0.1') + '/oauth/kakao'; setKakaoAuthUrl( - `https://kauth.kakao.com/oauth/authorize?client_id=${env.KAKAO_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&state=${userUUID}`, + `${env.API_URL}/oauth2/authorization/kakao?redirect_to=${redirectUri}`, ); - }, [env.KAKAO_CLIENT_ID]); + // setKakaoAuthUrl('http://146.56.161.252:8080/oauth2/authorization/kakao'); + }, [env.API_URL]); return ( diff --git a/app/contexts/EnvContext.tsx b/app/contexts/EnvContext.tsx index ceb7313..7323636 100644 --- a/app/contexts/EnvContext.tsx +++ b/app/contexts/EnvContext.tsx @@ -1,4 +1,4 @@ import { createContext } from 'react'; -import { type ClientEnv } from '@server/constants/env'; +import { type ClientEnv } from '@server'; export const EnvContext = createContext(null); diff --git a/app/hooks/useEnv.tsx b/app/hooks/useEnv.tsx index c470b07..07b1c39 100644 --- a/app/hooks/useEnv.tsx +++ b/app/hooks/useEnv.tsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; import { EnvContext } from '@/contexts/EnvContext'; -import { type ClientEnv, clientEnvSchema } from '@server/constants/env'; +import { type ClientEnv, clientEnvSchema } from '@server'; /** * 클라이언트용 환경 변수를 얻는 hook diff --git a/app/hooks/useErrorToast.ts b/app/hooks/useErrorToast.ts new file mode 100644 index 0000000..710ad77 --- /dev/null +++ b/app/hooks/useErrorToast.ts @@ -0,0 +1,16 @@ +import { create } from 'zustand'; +import { type FrontendErrorResponse } from '@server'; + +interface ErrorToastStore { + error: FrontendErrorResponse | null; + setError: (newError: FrontendErrorResponse) => void; + clearError: () => void; +} + +const useErrorToast = create()((set) => ({ + error: null, + setError: (newError) => set({ error: newError }), + clearError: () => set({ error: null }), +})); + +export default useErrorToast; diff --git a/app/hooks/useTypedFetcher.ts b/app/hooks/useTypedFetcher.ts new file mode 100644 index 0000000..c5892dc --- /dev/null +++ b/app/hooks/useTypedFetcher.ts @@ -0,0 +1,37 @@ +import { type TypedResponse } from '@remix-run/cloudflare'; +// `useTypedFetcher`를 사용하라는 규칙인데 이 파일이 바로 그 구현체이므로 무시 +// eslint-disable-next-line no-restricted-imports +import { useFetcher } from '@remix-run/react'; +import { useEffect } from 'react'; +import useErrorToast from './useErrorToast'; +import { + type FrontendErrorResponse, + type FrontendSuccessResponse, + type JsonValue, +} from '@server'; + +const useTypedFetcher = < + T extends ( + ...params: unknown[] + ) => Promise< + | TypedResponse> + | TypedResponse> + >, +>( + ...params: Parameters> +) => { + const fetcher = useFetcher(...params); + const { setError } = useErrorToast(); + + useEffect(() => { + if (fetcher.data !== undefined && !fetcher.data.isSuccess) { + setError(fetcher.data); + } + // `fetcher.data` 외 다른 것이 변할 때는 실행되면 안 됨 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.data]); + + return fetcher; +}; + +export default useTypedFetcher; diff --git a/app/root.css.ts b/app/root.css.ts index 9f159bb..5433cc0 100644 --- a/app/root.css.ts +++ b/app/root.css.ts @@ -1,4 +1,7 @@ -import { globalFontFace, globalStyle } from '@vanilla-extract/css'; +import { globalFontFace, globalStyle, style } from '@vanilla-extract/css'; +import { textStyle } from './styles/text.css'; +import { themeVars } from './styles/theme.css'; +import { rgba } from './utils/style'; import SUITVariable from '@/assets/SUIT-Variable.woff2'; globalFontFace('SUIT-Variable', { @@ -25,3 +28,58 @@ globalStyle('body', { globalStyle('h1, h2, h3, h4, h5, h6, p', { margin: 0, }); + +export const loadingToast = style({ + position: 'fixed', + bottom: '32px', + left: '50%', + transform: 'translate(-50%, 0)', + backgroundColor: '#121212', + borderRadius: '12px', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingRight: '12px', + gap: '10px', + width: '180px', + height: '50px', +}); + +export const loadingLottie = style({ + width: '43px', + height: '43px', +}); + +export const loadingToastText = style([ + textStyle.subtitle2SB, + { color: themeVars.color.grayscale.white.hex }, +]); + +export const errorToastWrap = style({ + position: 'fixed', + bottom: '32px', + left: '50%', + transform: 'translate(-50%, 0)', + padding: '0 24px', + width: '100%', + maxWidth: '648px', +}); + +export const errorToast = style({ + backgroundColor: rgba(themeVars.color.system.caution.rgb, 0.7), + borderRadius: '12px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + padding: '12px 24px', +}); + +export const errorToastText = style([ + textStyle.subtitle2SB, + { + color: themeVars.color.grayscale.white.hex, + textAlign: 'center', + wordBreak: 'keep-all', + }, +]); diff --git a/app/root.tsx b/app/root.tsx index 2854c90..cf55a44 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,3 +1,4 @@ +import { DotLottieReact } from '@lottiefiles/dotlottie-react'; import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare'; import { Links, @@ -5,11 +6,15 @@ import { Outlet, Scripts, ScrollRestoration, + useFetchers, useLoaderData, + useRouteError, } from '@remix-run/react'; +import { useEffect, useRef } from 'react'; +import useErrorToast from './hooks/useErrorToast'; +import * as styles from './root.css'; import { EnvContext } from '@/contexts/EnvContext'; -import './root.css'; import '@/styles/theme.css'; export const loader = async (args: LoaderFunctionArgs) => @@ -19,6 +24,24 @@ export const loader = async (args: LoaderFunctionArgs) => const App = () => { const data = useLoaderData(); + const fetchers = useFetchers(); + const { error, clearError } = useErrorToast(); + const errorToastRemovalTimeoutIdRef = useRef(null); + + useEffect(() => { + if (error) { + if (errorToastRemovalTimeoutIdRef.current) { + clearTimeout(errorToastRemovalTimeoutIdRef.current); + } + const timeoutId = setTimeout(() => { + clearError(); + }, 5000); + errorToastRemovalTimeoutIdRef.current = timeoutId; + } + + // `error` 외 다른 것이 바뀔 때는 호출되지 않아야 함 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [error]); return ( @@ -31,6 +54,21 @@ const App = () => { + {fetchers.length > 0 && ( +
+
+ +
+ 서버 통신 중... +
+ )} + {error && ( +
+
+ {error.message} +
+
+ )}
@@ -39,4 +77,11 @@ const App = () => { ); }; +export const ErrorBoundary = () => { + const error = useRouteError(); + console.log('ErrorBoundary', error); + + return

Error

; +}; + export default App; diff --git a/app/routes/oauth.kakao.tsx b/app/routes/oauth.kakao.tsx index c182ef8..d3be852 100644 --- a/app/routes/oauth.kakao.tsx +++ b/app/routes/oauth.kakao.tsx @@ -1,15 +1,23 @@ import { type LoaderFunctionArgs } from '@remix-run/cloudflare'; export const loader = async ({ request }: LoaderFunctionArgs) => { - const url = new URL(request.url); - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - - console.log(code, state); + const cookieHeader = request.headers; + console.log(cookieHeader); return null; }; -const KakaoRedirect = () =>
카카오 로그인 중...
; +const KakaoRedirect = () => { + console.log('kakaoredirect'); + + return ( +
+ {/*

{fetcher.state}

+ + + */} +
+ ); +}; export default KakaoRedirect; diff --git a/functions/[[path]].ts b/functions/[[path]].ts index 2e242ff..8facdd0 100644 --- a/functions/[[path]].ts +++ b/functions/[[path]].ts @@ -3,12 +3,13 @@ import { clientEnvSchema, type ContextEnv, serverEnvSchema, -} from '@server/constants/env'; -import { getLoadContext, makeAuthSession } from '@server/utils/cloudflare'; + getLoadContext, + makeAuthSession, +} from '@server'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - the server build file is generated by `remix vite:build` -// eslint-disable-next-line import/no-unresolved, import/order +// eslint-disable-next-line import/no-unresolved, import/order, no-restricted-imports import * as build from '../build/server'; export const onRequest = async ( diff --git a/package.json b/package.json index 00a2421..98101fc 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build-storybook": "storybook build" }, "dependencies": { + "@lottiefiles/dotlottie-react": "0.8.12", "@remix-run/cloudflare": "2.11.2", "@remix-run/cloudflare-pages": "2.11.2", "@remix-run/react": "2.11.2", @@ -25,7 +26,8 @@ "react": "18.3.1", "react-dom": "18.3.1", "uuid": "10.0.0", - "zod": "3.23.8" + "zod": "3.23.8", + "zustand": "4.5.5" }, "devDependencies": { "@commitlint/cli": "19.3.0", @@ -40,6 +42,7 @@ "@storybook/react": "8.2.9", "@storybook/react-vite": "8.2.9", "@storybook/test": "8.2.9", + "@types/eslint": "8.56.12", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", "@types/set-cookie-parser": "2.4.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b85e08a..9ca046e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@lottiefiles/dotlottie-react': + specifier: 0.8.12 + version: 0.8.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@remix-run/cloudflare': specifier: 2.11.2 version: 2.11.2(@cloudflare/workers-types@4.20240903.0)(typescript@5.5.3) @@ -38,6 +41,9 @@ importers: zod: specifier: 3.23.8 version: 3.23.8 + zustand: + specifier: 4.5.5 + version: 4.5.5(@types/react@18.3.3)(react@18.3.1) devDependencies: '@commitlint/cli': specifier: 19.3.0 @@ -75,6 +81,9 @@ importers: '@storybook/test': specifier: 8.2.9 version: 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))) + '@types/eslint': + specifier: 8.56.12 + version: 8.56.12 '@types/react': specifier: 18.3.3 version: 18.3.3 @@ -113,7 +122,7 @@ importers: version: 6.9.0(eslint@8.57.0) eslint-plugin-prettier: specifier: 5.2.1 - version: 5.2.1(eslint@8.57.0)(prettier@3.3.3) + version: 5.2.1(@types/eslint@8.56.12)(eslint@8.57.0)(prettier@3.3.3) eslint-plugin-react: specifier: 7.34.3 version: 7.34.3(eslint@8.57.0) @@ -1541,6 +1550,15 @@ packages: '@jspm/core@2.0.1': resolution: {integrity: sha512-Lg3PnLp0QXpxwLIAuuJboLeRaIhrgJjeuh797QADg3xz8wGLugQOS5DpsE8A6i6Adgzf+bacllkKZG3J0tGfDw==} + '@lottiefiles/dotlottie-react@0.8.12': + resolution: {integrity: sha512-6S9q5mH2l8JDnKAruAq4NP7bzHVzJNUt8UsXONfveSyJPUgT5hGw5b5vCjW19Ra3hgN/d69qUtRLHfmyRAgblQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@lottiefiles/dotlottie-web@0.33.0': + resolution: {integrity: sha512-/KerIs0UkmwuDKYWlNGWYSSZkZaLNXXHqg6qCnP7P2JuzgI4bHRmlIk3+LoJjl/jWqd3WyI4t5oRV6ch0sQwvg==} + '@mdx-js/mdx@2.3.0': resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==} @@ -2049,6 +2067,9 @@ packages: '@types/escodegen@0.0.6': resolution: {integrity: sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig==} + '@types/eslint@8.56.12': + resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -5594,6 +5615,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.2.2: + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5826,6 +5852,21 @@ packages: zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + zustand@4.5.5: + resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -7176,6 +7217,14 @@ snapshots: '@jspm/core@2.0.1': {} + '@lottiefiles/dotlottie-react@0.8.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@lottiefiles/dotlottie-web': 0.33.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@lottiefiles/dotlottie-web@0.33.0': {} + '@mdx-js/mdx@2.3.0': dependencies: '@types/estree-jsx': 1.0.5 @@ -7895,6 +7944,11 @@ snapshots: '@types/escodegen@0.0.6': {} + '@types/eslint@8.56.12': + dependencies: + '@types/estree': 1.0.5 + '@types/json-schema': 7.0.15 + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.5 @@ -9313,12 +9367,14 @@ snapshots: safe-regex-test: 1.0.3 string.prototype.includes: 2.0.0 - eslint-plugin-prettier@5.2.1(eslint@8.57.0)(prettier@3.3.3): + eslint-plugin-prettier@5.2.1(@types/eslint@8.56.12)(eslint@8.57.0)(prettier@3.3.3): dependencies: eslint: 8.57.0 prettier: 3.3.3 prettier-linter-helpers: 1.0.0 synckit: 0.9.1 + optionalDependencies: + '@types/eslint': 8.56.12 eslint-plugin-react-hooks@4.6.2(eslint@8.57.0): dependencies: @@ -12180,6 +12236,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.2.2(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} util@0.12.5: @@ -12429,4 +12489,11 @@ snapshots: zod@3.23.8: {} + zustand@4.5.5(@types/react@18.3.3)(react@18.3.1): + dependencies: + use-sync-external-store: 1.2.2(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + react: 18.3.1 + zwitch@2.0.4: {} diff --git a/public/loading-lottie.json b/public/loading-lottie.json new file mode 100644 index 0000000..0578ca1 --- /dev/null +++ b/public/loading-lottie.json @@ -0,0 +1 @@ +{"v":"5.6.10","fr":30,"ip":0,"op":26,"w":600,"h":600,"nm":"圆环-1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"形状图层 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300,300,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[230,230],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":30,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"椭圆 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":80,"ix":1},"e":{"a":0,"k":100,"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0],"e":[360]},{"t":25}],"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":150,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"形状图层 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300,300,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[230,230],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.302314070159,0.302314070159,0.302314070159,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":30,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"椭圆 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":150,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"黑底","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300,300,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[400,400],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":40,"ix":4},"nm":"矩形路径 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.074509803922,0.074509803922,0.074509803922,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"矩形 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":150,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/server/README.md b/server/README.md deleted file mode 100644 index 3aa2c1c..0000000 --- a/server/README.md +++ /dev/null @@ -1,67 +0,0 @@ -## About - -`server` 디렉토리는 Cloudflare 런타임 환경에 돌아갈 기능 등 서버와 관련이 있는 코드가 있는 곳이다. - -## ⚠️ Caution: Absolute Path - -`server` 내에서는 절대 경로(absolute path)를 사용하여 import하면 안 된다. - -Vite 플러그인 등 절대 경로가 적용되기 전에 평가되어야 하는 코드가 있기 때문에, 절대 경로를 사용하면 module resolution 오류가 발생한다. - -```typescript -// error -import { clientEnvSchema } from '@server/constants/env'; - -// ok -import { clientEnvSchema } from '../constants/env'; -``` - -그러나 `server` 외부에서는 사용해도 대부분 괜찮다. - -```typescript -// app/hooks/useEnv.tsx - -// ok -import { type ClientEnv, clientEnvSchema } from '@server/constants/env'; -``` - -## `remixCloudflareDevProxyVitePlugin` - -Cloudflare Pages는 일반적인 Node 환경이 아니기에, `wrangler`로 실행해야 실제 배포 환경에 맞게 실행해볼 수 있다. - -그러나 Remix.js는 `wrangler`를 사용하려면 먼저 빌드를 한 뒤 빌드 결과물을 실행해야 하기에, 개발 중 Live Reload가 동작하지 않는다. - -[`@remix-run/dev`의 `cloudflareDevProxyVitePlugin`을 사용하면](https://remix.run/docs/en/main/guides/vite#cloudflare-proxy) `wrangler`를 사용하지 않고도 Vite 개발 환경에서 Cloudflare Pages 환경에 맞게 쓰인 코드를 문제 없이 실행할 수 있지만, 현재 프로젝트 구성상 문제가 있다. - -우리 프로젝트는 `authSessionStorage`라는 cookie session storage를 생성한 뒤, `loader` 또는 `action`의 Response가 반환되기 직전에 `commitSession`을 실행해서 `Set-Cookie` header를 설정하도록 되어 있다. - -```typescript -// functions/[[path]].ts - -const handleRequest = createPagesFunctionHandler({ - build, - getLoadContext: (args) => getLoadContext(authSession, args), -}); - -const response = await handleRequest(context); - -response.headers.append( - 'Set-Cookie', - await authSessionStorage.commitSession(authSession), -); -``` - -그러나 `cloudflareDevProxyVitePlugin`은 `getLoadContext`만 parameter로 받고 있어 `Response`에 접근할 수 없기에, 모든 Remix.js의 endpoint에서 매번 `commitSession`을 수행해야만 한다. - -따라서 기존 플러그인 코드를 직접 수정하여 `commitSession`을 수행하도록 만들었다. - -```typescript -// server/plugins/remixCloudflareDevProxyVitePlugin.ts - -const res = await handler(req, loadContext); - -res.headers.append( - 'Set-Cookie', - await authSessionStorage.commitSession(authSession), -); -``` diff --git a/server/constants/api.ts b/server/constants/api.ts index 6c5dff0..77097c9 100644 --- a/server/constants/api.ts +++ b/server/constants/api.ts @@ -1,34 +1,122 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -interface ClientError { - status?: number; - message?: string; - path: string; - /** TODO: 서버 쪽 schema 전달되면 타입 수정 */ - serverError?: any; - request?: any; - clientError?: any; +import type { ApiRequest, BackendError } from '../types/api'; + +export class Api { + public method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + public endpoint: `/${string}`; + public needToLogin: boolean; + public baseUrl?: string; + public errorMessage?: { + messageByStatus?: Record; + }; + public request: (variables: Variables) => ApiRequest; + + constructor(apiInfo: { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + endpoint: `/${string}`; + needToLogin?: boolean; + baseUrl?: string; + errorMessage?: { + messageByStatus?: Record; + }; + request: (variables: Variables) => ApiRequest; + }) { + this.method = apiInfo.method; + this.endpoint = apiInfo.endpoint; + this.needToLogin = apiInfo.needToLogin ?? false; + this.baseUrl = apiInfo.baseUrl; + this.errorMessage = apiInfo.errorMessage; + this.request = apiInfo.request; + } + + getFetchInfo( + variables: Variables, + accessToken?: string, + ): { + pathname: string; + method: Api['method']; + headers: ApiRequest['headers']; + body?: string | FormData; + request: ApiRequest; + } { + const parsedRequest = this.request(variables); + + const pathString = + parsedRequest.pathParams?.reduce( + (prev, cur) => `${prev}/${cur}`, + '', + ) ?? ''; + + const params = parsedRequest.queryParams ?? {}; + const queryString = Object.keys(params).reduce( + (prev, cur) => + `${prev}${ + params[cur] !== null && params[cur] !== undefined + ? `&${cur}=${params[cur]}` + : '' + }`, + '', + ); + + const pathname = `${this.endpoint}${pathString}${ + queryString ? `?${queryString.slice(1)}` : '' + }`; + + const authorizationHeader = accessToken + ? { + Authorization: `Bearer ${accessToken}`, + } + : undefined; + + return { + pathname, + method: this.method, + headers: { + 'Content-Type': + parsedRequest.body instanceof FormData + ? 'multipart/form-data' + : 'application/json', + ...authorizationHeader, + ...parsedRequest.headers, + }, + body: + // eslint-disable-next-line no-nested-ternary + parsedRequest.body !== undefined + ? parsedRequest.body instanceof FormData + ? parsedRequest.body + : JSON.stringify(parsedRequest.body) + : undefined, + request: parsedRequest, + }; + } } export class ApiError extends Error { public status?: number; - public path: string; + public serverError?: BackendError; - /** TODO: 서버 쪽 schema 전달되면 타입 수정 */ - public serverError?: any; + public api: Api; - public request?: any; + public request: ApiRequest; - public clientError?: any; + public frontendError?: unknown; - constructor(error: ClientError) { + constructor(error: { + status?: number; + message?: string; + backendError?: BackendError; + api: Api; + request: ApiRequest; + frontendError?: any; + }) { super(error.message ?? '문제가 발생했습니다. 잠시 후 다시 시도해주세요.'); this.name = 'ApiError'; - this.status = error.status ?? error.serverError?.status; - this.path = error.path; - this.serverError = error.serverError; + this.status = error.status ?? error.backendError?.status; + this.api = error.api; this.request = error.request; - this.clientError = error.clientError; + this.serverError = error.backendError; + this.frontendError = error.frontendError; } } diff --git a/server/constants/env.ts b/server/constants/env.ts index 225a688..6449f18 100644 --- a/server/constants/env.ts +++ b/server/constants/env.ts @@ -1,20 +1,10 @@ import { z } from 'zod'; export const clientEnvSchema = z.object({ - KAKAO_CLIENT_ID: z.string(), + API_URL: z.string(), }); export const serverEnvSchema = z.object({ AUTH_COOKIE_SESSION_SECRET: z.string(), API_URL: z.string(), }); - -export type ClientEnv = Readonly>; - -export type ServerEnv = Readonly>; - -export type CloudflareEnv = { - readonly KV_NAMESPACE: KVNamespace; -}; - -export type ContextEnv = ClientEnv & ServerEnv & CloudflareEnv; diff --git a/server/constants/types/api.ts b/server/constants/types/api.ts deleted file mode 100644 index e67ae36..0000000 --- a/server/constants/types/api.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export interface ApiRequest { - pathParams?: (string | number)[]; - headers?: Record; - params?: Record; - body?: any; -} - -export interface ApiInfo { - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - endpoint: `/${string}`; - needToLogin: boolean; - baseUrl?: string; - errorMessage?: { - messageByStatus?: Record; - }; - request: (variables: Variables) => ApiRequest; - _resultType?: Result; -} - -export type FetchApi = ( - apiInfo: ApiInfo, - variables: Variables, -) => Promise; diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..c8e8a80 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,27 @@ +export { Api, ApiError } from './constants/api'; +export { serverEnvSchema, clientEnvSchema } from './constants/env'; + +export type { + JsonValue, + ApiRequest, + FetchApi, + BackendError, + FrontendSuccessResponse, + FrontendErrorResponse, +} from './types/api'; +export type { + AuthSession, + AuthSessionData, + AuthSessionStorage, +} from './types/auth'; +export type { + ServerEnv, + ClientEnv, + CloudflareEnv, + ContextEnv, +} from './types/env'; + +export { makeSuccessResponse, makeErrorResponse } from './utils/api'; +export { getLoadContext, makeAuthSession } from './utils/cloudflare'; + +export { remixCloudflareDevProxyVitePlugin } from './vite'; diff --git a/server/plugins/remixCloudflareDevProxyVitePlugin.ts b/server/plugins/remixCloudflareDevProxyVitePlugin.ts deleted file mode 100644 index 80e2ba7..0000000 --- a/server/plugins/remixCloudflareDevProxyVitePlugin.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * `cloudflareDevProxyVitePlugin` 코드 원문을 가져와 수정 - * https://github.com/remix-run/remix/blob/main/packages/remix-dev/vite/cloudflare-proxy-plugin.ts - */ - -import { once } from 'node:events'; -import type { IncomingHttpHeaders, ServerResponse } from 'node:http'; -import { Readable } from 'node:stream'; -import { createRequestHandler, type ServerBuild } from '@remix-run/cloudflare'; -import { createReadableStreamFromReadable } from '@remix-run/node'; -import { splitCookiesString } from 'set-cookie-parser'; -import type * as Vite from 'vite'; -import { type GetPlatformProxyOptions } from 'wrangler'; -import type { ContextEnv } from '../constants/env'; -import { getLoadContext, makeAuthSession } from '../utils/cloudflare'; - -function invariant(value: boolean, message?: string): asserts value; - -function invariant( - value: T | null | undefined, - message?: string, -): asserts value is T; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function invariant(value: any, message?: string) { - if (value === false || value === null || typeof value === 'undefined') { - console.error( - 'The following error is a bug in Remix; please open an issue! https://github.com/remix-run/remix/issues/new', - ); - throw new Error(message); - } -} - -function fromNodeHeaders(nodeHeaders: IncomingHttpHeaders): Headers { - const headers = new Headers(); - - for (const [key, values] of Object.entries(nodeHeaders)) { - if (values) { - if (Array.isArray(values)) { - for (const value of values) { - headers.append(key, value); - } - } else { - headers.set(key, values); - } - } - } - - return headers; -} - -// Based on `createRemixRequest` in packages/remix-express/server.ts -export function fromNodeRequest( - nodeReq: Vite.Connect.IncomingMessage, -): Request { - const origin = - nodeReq.headers.origin && 'null' !== nodeReq.headers.origin - ? nodeReq.headers.origin - : `http://${nodeReq.headers.host}`; - // Use `req.originalUrl` so Remix is aware of the full path - invariant( - nodeReq.originalUrl, - 'Expected `nodeReq.originalUrl` to be defined', - ); - const url = new URL(nodeReq.originalUrl, origin); - const init: RequestInit = { - method: nodeReq.method, - headers: fromNodeHeaders(nodeReq.headers), - }; - - if (nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD') { - init.body = createReadableStreamFromReadable(nodeReq); - (init as { duplex: 'half' }).duplex = 'half'; - } - - return new Request(url.href, init); -} - -// Adapted from solid-start's `handleNodeResponse`: -// https://github.com/solidjs/solid-start/blob/7398163869b489cce503c167e284891cf51a6613/packages/start/node/fetch.js#L162-L185 -export async function toNodeRequest(res: Response, nodeRes: ServerResponse) { - nodeRes.statusCode = res.status; - nodeRes.statusMessage = res.statusText; - - const cookiesStrings = []; - - for (const [name, value] of res.headers) { - if (name === 'set-cookie') { - cookiesStrings.push(...splitCookiesString(value)); - } else nodeRes.setHeader(name, value); - } - - if (cookiesStrings.length) { - nodeRes.setHeader('set-cookie', cookiesStrings); - } - - if (res.body) { - // https://github.com/microsoft/TypeScript/issues/29867 - const responseBody = res.body as unknown as AsyncIterable; - const readable = Readable.from(responseBody); - readable.pipe(nodeRes); - await once(readable, 'end'); - } else { - nodeRes.end(); - } -} - -const serverBuildId = 'virtual:remix/server-build'; - -type CfProperties = Record; - -function importWrangler() { - try { - return import('wrangler'); - } catch (_) { - throw Error('Could not import `wrangler`. Do you have it installed?'); - } -} - -const NAME = 'vite-plugin-remix-cloudflare-proxy'; - -export const remixCloudflareDevProxyVitePlugin = ( - options: GetPlatformProxyOptions = {}, -): Vite.Plugin => ({ - name: NAME, - config: () => ({ - ssr: { - resolve: { - externalConditions: ['workerd', 'worker'], - }, - }, - }), - configResolved: (viteConfig) => { - const pluginIndex = (name: string) => - viteConfig.plugins.findIndex((plugin) => plugin.name === name); - const remixIndex = pluginIndex('remix'); - if (remixIndex >= 0 && remixIndex < pluginIndex(NAME)) { - throw new Error( - `The "${NAME}" plugin should be placed before the Remix plugin in your Vite config file`, - ); - } - }, - configureServer: async (viteDevServer) => { - const { getPlatformProxy } = await importWrangler(); - // Do not include `dispose` in Cloudflare context - const { dispose: _dispose, ...cloudflare } = await getPlatformProxy< - ContextEnv, - Cf - >(options); - const context = { cloudflare }; - return () => { - if (!viteDevServer.config.server.middlewareMode) { - viteDevServer.middlewares.use(async (nodeReq, nodeRes, next) => { - try { - const build = (await viteDevServer.ssrLoadModule( - serverBuildId, - )) as ServerBuild; - - const handler = createRequestHandler(build, 'development'); - const req = fromNodeRequest(nodeReq); - - // auth session 생성 - const { authSessionStorage, authSession } = await makeAuthSession( - { - authCookieSessionSecret: - context.cloudflare.env.AUTH_COOKIE_SESSION_SECRET, - kvNamespace: context.cloudflare.env.KV_NAMESPACE, - }, - nodeReq.headers.cookie, - ); - - const loadContext = await getLoadContext(authSession, { - request: req, - context, - }); - - const res = await handler(req, loadContext); - - // 자동 commit - res.headers.append( - 'Set-Cookie', - await authSessionStorage.commitSession(authSession), - ); - - await toNodeRequest(res, nodeRes); - } catch (error) { - next(error); - } - }); - } - }; - }, -}); diff --git a/server/types/api.ts b/server/types/api.ts new file mode 100644 index 0000000..1fc671c --- /dev/null +++ b/server/types/api.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { Api, ApiError } from '../constants/api'; + +type JsonPrimitive = string | number | boolean | null; +type JsonArray = JsonValue[] | readonly JsonValue[]; +type JsonObject = { + [K in string]: JsonValue; +} & { + [K in string]?: JsonValue; +}; +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +export interface ApiRequest { + pathParams?: (string | number)[]; + headers?: Record; + queryParams?: Record; + body?: any; +} + +export interface BackendError { + timestamp: string; + status: number; + error: string; + path: string; +} + +export interface FrontendResponseBase { + status: number; + result: T; +} + +export interface FrontendSuccessResponse + extends FrontendResponseBase { + isSuccess: true; +} + +export interface FrontendErrorResponse + extends FrontendResponseBase { + isSuccess: false; + message: string; +} + +export type ApiSuccessReturnType = { + isSuccess: true; + response: Result; +}; +export type ApiFailureReturnType = { + isSuccess: false; + error: ApiError; +}; + +export type ApiReturnType = + | ApiSuccessReturnType + | ApiFailureReturnType; + +export interface ApiOptions { + throwOnError?: boolean; +} + +export type FetchApi = { + ( + api: Api, + variables: Variables, + options?: ApiOptions & { + throwOnError?: false; + }, + ): Promise>; + ( + api: Api, + variables: Variables, + options?: ApiOptions & { + throwOnError: true; + }, + ): Promise>; +}; diff --git a/server/constants/types/auth.ts b/server/types/auth.ts similarity index 85% rename from server/constants/types/auth.ts rename to server/types/auth.ts index deec05a..a84927f 100644 --- a/server/constants/types/auth.ts +++ b/server/types/auth.ts @@ -1,4 +1,4 @@ -import type { Session, SessionStorage } from '@remix-run/cloudflare'; +import type { SessionStorage, Session } from '@remix-run/cloudflare'; export interface AuthSessionData { accessToken: string; diff --git a/server/types/env.ts b/server/types/env.ts new file mode 100644 index 0000000..8396c58 --- /dev/null +++ b/server/types/env.ts @@ -0,0 +1,12 @@ +import type { z } from 'zod'; +import type { clientEnvSchema, serverEnvSchema } from '../constants/env'; + +export type ClientEnv = Readonly>; + +export type ServerEnv = Readonly>; + +export type CloudflareEnv = { + readonly KV_NAMESPACE: KVNamespace; +}; + +export type ContextEnv = ClientEnv & ServerEnv & CloudflareEnv; diff --git a/server/utils/api.ts b/server/utils/api.ts index 6a35712..4f91ab7 100644 --- a/server/utils/api.ts +++ b/server/utils/api.ts @@ -1,8 +1,49 @@ -import { ApiError } from '../constants/api'; -import type { ApiInfo } from '../constants/types/api'; -import type { AuthSession } from '../constants/types/auth'; +import { json, TypedResponse } from '@remix-run/cloudflare'; +import { Api, ApiError } from '../constants/api'; +import type { + ApiOptions, + ApiReturnType, + BackendError, + FrontendErrorResponse, + FrontendSuccessResponse, + JsonValue, +} from '../types/api'; +import type { AuthSession } from '../types/auth'; import { getAuthToken } from './auth'; +type Optional = Pick, K> & Omit; + +export const makeSuccessResponse = ( + info: Omit, 'status'>, 'isSuccess'>, + init?: ResponseInit, +) => + json>( + { + ...info, + isSuccess: true, + status: info.status ?? 200, + }, + { status: info.status ?? 200, ...init }, + ); + +export const makeErrorResponse = ( + info: Omit< + Optional, 'status' | 'message'>, + 'isSuccess' + >, + init?: ResponseInit, +): TypedResponse> => + json>( + { + ...info, + isSuccess: false, + status: info.status ?? 500, + message: + info.message ?? '문제가 발생했습니다. 잠시 후 다시 시도해 주세요.', + }, + { status: info.status ?? 500, ...init }, + ); + const COMMON_ERROR: { errorByStatus: Record< number, @@ -18,79 +59,35 @@ const COMMON_ERROR: { }, }; -export const api = - () => - ( - apiInfo: Omit, '_resultType'>, - ): ApiInfo => - apiInfo; - -export const fetchApi = async ( - apiInfo: ApiInfo, +export const fetchApiImpl = async ( + api: Api, variables: Variables, apiUrl: string, authSession?: AuthSession, -): Promise => { - const baseUrl = apiInfo.baseUrl ?? apiUrl; - const url = `${baseUrl}${apiInfo.endpoint}`; - const parsedRequest = apiInfo.request(variables); - - const pathString = - parsedRequest.pathParams?.reduce( - (prev, cur) => `${prev}/${cur}`, - '', - ) ?? ''; - - const params = parsedRequest.params ?? {}; - const queryString = Object.keys(params).reduce( - (prev, cur) => - `${prev}${ - params[cur] !== null && params[cur] !== undefined - ? `&${cur}=${params[cur]}` - : '' - }`, - '', - ); - - const fetchUrl = `${url}${pathString}${ - queryString ? `?${queryString.slice(1)}` : '' - }`; - + options?: ApiOptions, +): Promise> => { try { + const baseUrl = api.baseUrl ?? apiUrl; + const token = authSession ? await getAuthToken(authSession, apiUrl) : null; - if (!token?.accessToken && apiInfo.needToLogin) { + const fetchInfo = api.getFetchInfo(variables, token?.accessToken); + + const fetchUrl = `${baseUrl}${fetchInfo.pathname}`; + + if (!token?.accessToken && api.needToLogin) { throw new ApiError({ status: 401, - path: fetchUrl, - request: parsedRequest, + api, + request: fetchInfo.request, ...COMMON_ERROR.errorByStatus[401], }); } - const authorizationHeader = token?.accessToken - ? { - Authorization: `Bearer ${token.accessToken}`, - } - : undefined; - const response = await fetch(fetchUrl, { - method: apiInfo.method, - body: - // eslint-disable-next-line no-nested-ternary - parsedRequest.body !== undefined - ? parsedRequest.body instanceof FormData - ? parsedRequest.body - : JSON.stringify(parsedRequest.body) - : undefined, - headers: { - 'Content-Type': - parsedRequest.body instanceof FormData - ? 'multipart/form-data' - : 'application/json', - ...authorizationHeader, - ...parsedRequest.headers, - }, + method: fetchInfo.method, + body: fetchInfo.body, + headers: fetchInfo.headers, }); // `Result`가 `null`인 경우가 있지만 이는 try-catch에 의한 것으로, 타입 체계상에서는 분기처리할 수 없음 @@ -99,17 +96,18 @@ export const fetchApi = async ( try { result = await response.json(); } catch (error) { - console.log(error, `${apiInfo.method} ${apiInfo.endpoint}`); + console.log(error, `${api.method} ${api.endpoint}`); result = null; } if (response.ok) { - return result; + return { + isSuccess: true, + response: result, + }; } - // TODO: 서버 쪽 schema 전달되면 타입 수정 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const serverError: any = result; + const backendError: BackendError | null = result; const error: { message?: string; @@ -121,32 +119,45 @@ export const fetchApi = async ( // error message by status - 2nd priority error.message = - apiInfo.errorMessage?.messageByStatus?.[response.status]?.message ?? + api.errorMessage?.messageByStatus?.[response.status]?.message ?? error.message; // error message from server - 1st priority - error.message = serverError?.message ?? error.message; + error.message = backendError?.error ?? error.message; throw new ApiError({ ...error, status: response.status, - path: apiInfo.endpoint, - serverError: result, - request: parsedRequest, + api, + backendError: backendError ?? undefined, + request: fetchInfo.request, }); } catch (error) { // 이미 처리된 에러는 그대로 반환 if (error instanceof ApiError) { console.log(error.serverError); - throw error; + if (options?.throwOnError) { + throw error; + } + return { + isSuccess: false, + error: error, + }; } // TODO: Sentry 등 에러 로깅 솔루션 추가 - console.error(error, apiInfo.request(variables), fetchUrl); - throw new ApiError({ - path: apiInfo.endpoint, - request: parsedRequest, - clientError: error, + console.error(error, api, variables); + const apiError = new ApiError({ + api, + request: api.request(variables), + frontendError: error, }); + if (options?.throwOnError) { + throw apiError; + } + return { + isSuccess: false, + error: apiError, + }; } }; diff --git a/server/utils/auth.ts b/server/utils/auth.ts index fc48462..a11eff1 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,4 +1,5 @@ -import type { AuthSession, AuthSessionData } from '../constants/types/auth'; +import { Api, ApiError } from '../constants/api'; +import type { AuthSession, AuthSessionData } from '../types/auth'; export const clearAuthToken = async (authSession: AuthSession) => { authSession.unset('accessToken'); @@ -18,6 +19,18 @@ export const updateAuthToken = async ( }); }; +const api_getNewAccessToken = new Api< + { refreshToken: string }, + { accessToken: string; accessTokenExpiresAt: string } +>({ + method: 'POST', + endpoint: '/token/access', + needToLogin: false, + request: (variables) => ({ + body: { refreshToken: variables.refreshToken }, + }), +}); + export const getAuthToken = async ( authSession: AuthSession, apiUrl: string, @@ -50,29 +63,35 @@ export const getAuthToken = async ( // refresh token이 만료되지 않음 (서버 응답 시간 등을 고려해 1분 여유 포함) if (refreshTokenExpiresAt.getTime() + 1000 * 60 < now.getTime()) { try { - const response = await fetch(`${apiUrl}/token/access`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - refreshToken, - }), + const fetchInfo = api_getNewAccessToken.getFetchInfo({ + refreshToken, }); - const result = await response.json<{ - accessToken: string; - accessTokenExpiresAt: string; - }>(); - - await updateAuthToken(authSession, { - accessToken: result.accessToken, - accessTokenExpiresAt: result.accessTokenExpiresAt, + const response = await fetch(`${apiUrl}${fetchInfo.pathname}`, { + method: fetchInfo.method, + headers: fetchInfo.headers, + body: fetchInfo.body, }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await response.json(); - return { - accessToken: result.accessToken, - refreshToken, - }; + if (response.ok) { + await updateAuthToken(authSession, { + accessToken: result.accessToken, + accessTokenExpiresAt: result.accessTokenExpiresAt, + }); + + return { + accessToken: result.accessToken, + refreshToken, + }; + } + + throw new ApiError({ + status: response.status, + backendError: result, + api: api_getNewAccessToken, + request: fetchInfo.request, + }); } catch (error) { console.error(error); } diff --git a/server/utils/cloudflare.ts b/server/utils/cloudflare.ts index 17fc747..19f18a8 100644 --- a/server/utils/cloudflare.ts +++ b/server/utils/cloudflare.ts @@ -4,22 +4,52 @@ import { } from '@remix-run/cloudflare'; import { type GetLoadContextFunction } from '@remix-run/cloudflare-pages'; import { type PlatformProxy } from 'wrangler'; -import { - type CloudflareEnv, - type ContextEnv, - type ClientEnv, - type ServerEnv, - clientEnvSchema, - serverEnvSchema, -} from '../constants/env'; -import type { FetchApi } from '../constants/types/api'; -import type { AuthSession, AuthSessionData } from '../constants/types/auth'; -import { fetchApi } from '../utils/api'; +import { clientEnvSchema, serverEnvSchema } from '../constants/env'; +import type { FetchApi } from '../types/api'; +import type { AuthSession, AuthSessionData } from '../types/auth'; +import type { + ClientEnv, + CloudflareEnv, + ContextEnv, + ServerEnv, +} from '../types/env'; +import { fetchApiImpl } from '../utils/api'; declare module '@remix-run/cloudflare' { interface AppLoadContext extends ServerEnv, CloudflareEnv { readonly clientEnv: ClientEnv; readonly authSession: AuthSession; + /** + * `ApiInfo` 객체로 백엔드 API를 호출하는 함수 + * @example + * ``` + * import { type ActionFunctionArgs } from '@remix-run/cloudflare'; + * import { api_getPost } from '@/apis/post'; + * + * export const action = async ({ context }: ActionFunctionArgs) => { + * const apiReturn = await context.fetchApi(api_getPost, { id: 4 }); + * if (!apiReturn.isSuccess) { + * return apiReturn.errorResponse; + * } + * const { response } = apiReturn; + * // ... + * }; + * ``` + * - `loader`와 같이 오류 발생 시 `ErrorBoundary`로 이동해야 하는 경우 `throwOnError` 옵션 사용 + * ``` + * import { type LoaderFunctionArgs } from '@remix-run/cloudflare'; + * import { api_getPost } from '@/apis/post'; + * + * export const loader = async ({ context }: LoaderFunctionArgs) => { + * const { response } = await context.fetchApi( + * api_getPost, + * { id: 4 }, + * { throwOnError: true }, + * ); + * // ... + * }; + * ``` + */ readonly fetchApi: FetchApi; } } @@ -43,8 +73,14 @@ export const getLoadContext: ( ...context.cloudflare.env, clientEnv, authSession, - fetchApi: async (apiInfo, variables) => - fetchApi(apiInfo, variables, context.cloudflare.env.API_URL, authSession), + fetchApi: (async (api, variables, options) => + fetchApiImpl( + api, + variables, + context.cloudflare.env.API_URL, + authSession, + options, + )) as FetchApi, }; }; diff --git a/server/vite/index.ts b/server/vite/index.ts new file mode 100644 index 0000000..dd1d178 --- /dev/null +++ b/server/vite/index.ts @@ -0,0 +1 @@ +export { default as remixCloudflareDevProxyVitePlugin } from './remixCloudflareDevProxyVitePlugin'; diff --git a/server/vite/invariant.ts b/server/vite/invariant.ts new file mode 100644 index 0000000..955c063 --- /dev/null +++ b/server/vite/invariant.ts @@ -0,0 +1,18 @@ +// https://github.com/remix-run/remix/blob/main/packages/remix-dev/invariant.ts + +export default function invariant( + value: boolean, + message?: string, +): asserts value; + +export default function invariant( + value: T | null | undefined, + message?: string, +): asserts value is T; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default function invariant(value: any, message?: string) { + if (value === false || value === null || typeof value === 'undefined') { + throw new Error(message); + } +} diff --git a/server/vite/nodeAdapter.ts b/server/vite/nodeAdapter.ts new file mode 100644 index 0000000..fe438b0 --- /dev/null +++ b/server/vite/nodeAdapter.ts @@ -0,0 +1,83 @@ +// https://github.com/remix-run/remix/blob/main/packages/remix-dev/vite/node-adapter.ts + +import { once } from 'node:events'; +import type { IncomingHttpHeaders, ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; +import { createReadableStreamFromReadable } from '@remix-run/node'; +import { splitCookiesString } from 'set-cookie-parser'; +import type * as Vite from 'vite'; +import invariant from './invariant'; + +function fromNodeHeaders(nodeHeaders: IncomingHttpHeaders): Headers { + const headers = new Headers(); + + for (const [key, values] of Object.entries(nodeHeaders)) { + if (values) { + if (Array.isArray(values)) { + for (const value of values) { + headers.append(key, value); + } + } else { + headers.set(key, values); + } + } + } + + return headers; +} + +// Based on `createRemixRequest` in packages/remix-express/server.ts +export function fromNodeRequest( + nodeReq: Vite.Connect.IncomingMessage, +): Request { + const origin = + nodeReq.headers.origin && 'null' !== nodeReq.headers.origin + ? nodeReq.headers.origin + : `http://${nodeReq.headers.host}`; + // Use `req.originalUrl` so Remix is aware of the full path + invariant( + nodeReq.originalUrl, + 'Expected `nodeReq.originalUrl` to be defined', + ); + const url = new URL(nodeReq.originalUrl, origin); + const init: RequestInit = { + method: nodeReq.method, + headers: fromNodeHeaders(nodeReq.headers), + }; + + if (nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD') { + init.body = createReadableStreamFromReadable(nodeReq); + (init as { duplex: 'half' }).duplex = 'half'; + } + + return new Request(url.href, init); +} + +// Adapted from solid-start's `handleNodeResponse`: +// https://github.com/solidjs/solid-start/blob/7398163869b489cce503c167e284891cf51a6613/packages/start/node/fetch.js#L162-L185 +export async function toNodeRequest(res: Response, nodeRes: ServerResponse) { + nodeRes.statusCode = res.status; + nodeRes.statusMessage = res.statusText; + + const cookiesStrings = []; + + for (const [name, value] of res.headers) { + if (name === 'set-cookie') { + cookiesStrings.push(...splitCookiesString(value)); + } else nodeRes.setHeader(name, value); + } + + if (cookiesStrings.length) { + nodeRes.setHeader('set-cookie', cookiesStrings); + } + + if (res.body) { + // https://github.com/microsoft/TypeScript/issues/29867 + const responseBody = res.body as unknown as AsyncIterable; + const readable = Readable.from(responseBody); + readable.pipe(nodeRes); + await once(readable, 'end'); + } else { + nodeRes.end(); + } +} diff --git a/server/vite/remixCloudflareDevProxyVitePlugin.ts b/server/vite/remixCloudflareDevProxyVitePlugin.ts new file mode 100644 index 0000000..c733870 --- /dev/null +++ b/server/vite/remixCloudflareDevProxyVitePlugin.ts @@ -0,0 +1,95 @@ +// https://github.com/remix-run/remix/blob/main/packages/remix-dev/vite/cloudflare-proxy-plugin.ts + +import { createRequestHandler, type ServerBuild } from '@remix-run/cloudflare'; +import type * as Vite from 'vite'; +import { type GetPlatformProxyOptions } from 'wrangler'; +import type { ContextEnv } from '../types/env'; +import { getLoadContext, makeAuthSession } from '../utils/cloudflare'; + +const serverBuildId = 'virtual:remix/server-build'; + +type CfProperties = Record; + +const NAME = 'vite-plugin-remix-cloudflare-proxy'; + +const remixCloudflareDevProxyVitePlugin = ( + options: GetPlatformProxyOptions = {}, +): Vite.Plugin => ({ + name: NAME, + config: () => ({ + ssr: { + resolve: { + externalConditions: ['workerd', 'worker'], + }, + }, + // https://github.com/sveltejs/kit/issues/8140 + optimizeDeps: { exclude: ['fsevents'] }, + }), + configResolved: (viteConfig) => { + const pluginIndex = (name: string) => + viteConfig.plugins.findIndex((plugin) => plugin.name === name); + const remixIndex = pluginIndex('remix'); + if (remixIndex >= 0 && remixIndex < pluginIndex(NAME)) { + throw new Error( + `The "${NAME}" plugin should be placed before the Remix plugin in your Vite config file`, + ); + } + }, + configureServer: async (viteDevServer) => { + const [{ getPlatformProxy }, { fromNodeRequest, toNodeRequest }] = + await Promise.all([ + import('wrangler'), + // Node.js 의존성은 동적으로 import + import('./nodeAdapter'), + ]); + // Do not include `dispose` in Cloudflare context + const { dispose: _dispose, ...cloudflare } = await getPlatformProxy< + ContextEnv, + Cf + >(options); + const context = { cloudflare }; + return () => { + if (!viteDevServer.config.server.middlewareMode) { + viteDevServer.middlewares.use(async (nodeReq, nodeRes, next) => { + try { + const build = (await viteDevServer.ssrLoadModule( + serverBuildId, + )) as ServerBuild; + + const handler = createRequestHandler(build, 'development'); + const req = fromNodeRequest(nodeReq); + + // auth session 생성 + const { authSessionStorage, authSession } = await makeAuthSession( + { + authCookieSessionSecret: + context.cloudflare.env.AUTH_COOKIE_SESSION_SECRET, + kvNamespace: context.cloudflare.env.KV_NAMESPACE, + }, + nodeReq.headers.cookie, + ); + + const loadContext = await getLoadContext(authSession, { + request: req, + context, + }); + + const res = await handler(req, loadContext); + + // 자동 commit + res.headers.append( + 'Set-Cookie', + await authSessionStorage.commitSession(authSession), + ); + + await toNodeRequest(res, nodeRes); + } catch (error) { + next(error); + } + }); + } + }; + }, +}); + +export default remixCloudflareDevProxyVitePlugin; diff --git a/tsconfig.json b/tsconfig.json index 36cfdd6..28f5517 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,7 @@ "baseUrl": ".", "paths": { "@/*": ["./app/*"], - "@server/*": ["./server/*"] + "@server": ["./server"] }, // Vite takes care of building everything, not tsc. diff --git a/vite.config.ts b/vite.config.ts index 2050f25..c5c75d1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,7 @@ import { vitePlugin as remix } from '@remix-run/dev'; import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; -import { remixCloudflareDevProxyVitePlugin } from './server/plugins/remixCloudflareDevProxyVitePlugin'; +import { remixCloudflareDevProxyVitePlugin } from './server'; export default defineConfig({ plugins: [