Skip to content

Commit

Permalink
[ORT-3] feat: add api and auth base code (#3)
Browse files Browse the repository at this point in the history
* feat: add auth cookie session storage

* refactor: add `AuthSessionStorage` type

* feat: add api base code

* refactor: remove unnecessary `if` & treat nullish `serverError`

* build: update `pnpm-lock.yaml`

* feat: use cloudflare env only & modify dev proxy plugin

* feat: add `.infisical.json`

* feat: add api base code (incomplete)

* fix: load dev proxy plugin only on dev

* feat: add custom dev proxy vite plugin & move `fetchApi` into `Api` model

* feat: modify api types & add comments

* feat: add `AuthSessionService`

* feat: remove unused dependency

* fix(style): fix wrong comment

* refactor: rename `ApiReturnType` & fix comments

* refactor: simplify `getFetchInfo` logic

* feat: add regex to check `API_URL`

* feat: add sentry & use vite env & add kakao oauth

* refactor: remove unnecessary console

* feat: add `extraErrorDataIntegration`

* fix: remove sourcemap files after upload for safety
  • Loading branch information
aube-dev authored Sep 23, 2024
1 parent a5cd638 commit 736df0f
Show file tree
Hide file tree
Showing 37 changed files with 4,202 additions and 1,379 deletions.
61 changes: 61 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ module.exports = {
],
format: ['PascalCase'],
},
{
selector: 'typeParameter',
format: ['PascalCase'],
prefix: ['_'],
filter: '^_',
},
{
selector: ['enumMember'],
format: ['UPPER_CASE'],
Expand All @@ -117,6 +123,12 @@ module.exports = {
selector: ['objectLiteralProperty'],
format: null,
},
{
selector: 'variable',
format: ['camelCase'],
prefix: ['api_', 'unstable_'],
filter: '(^api_|^unstable_)',
},
],
},
},
Expand All @@ -128,6 +140,29 @@ module.exports = {
node: true,
},
},

{
files: [
'./server/**/*.{ts,tsx}',
'./vite.config.ts',
'./vite.config.storybook.ts',
],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
// https://github.com/vitejs/vite/issues/10063
group: ['@/*', '@server', 'app/*', 'server/*'],
message:
'server 디렉토리, 또는 Vite config 파일에서는 상대 경로를 사용해 주세요.',
},
],
},
],
},
},
],

rules: {
Expand All @@ -142,5 +177,31 @@ 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`를 사용해 주세요.',
},
{
group: ['@remix-run/cloudflare'],
importNames: ['redirect'],
message:
'`@/utils/server`의 `redirectWithAuthCookie`를 사용해 주세요.',
},
],
},
],
},
};
11 changes: 9 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ node_modules

/.cache
/build
.env
.wrangler

# macOS
.DS_Store
*storybook.log
*storybook.log

# env
.env
.env.local
.dev.vars

wrangler.toml
5 changes: 5 additions & 0 deletions .infisical.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"workspaceId": "6a0e9b92-038c-4045-beeb-7d9914ef86fb",
"defaultEnvironment": "dev",
"gitBranchToEnvironmentMapping": null
}
2 changes: 1 addition & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
21 changes: 21 additions & 0 deletions app/apis/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Api } from '@/models/api';

export const api_loginWithOauth = new Api<
{ oauthKey: string },
{
userId: number;
accessToken: string;
accessTokenExpiresAt: string;
refreshToken: string;
refreshTokenExpiresAt: string;
}
>({
method: 'GET',
endpoint: '/oauth2/token',
needToLogin: false,
request: (variables) => ({
queryParams: {
oauthKey: variables.oauthKey,
},
}),
});
24 changes: 5 additions & 19 deletions app/components/KakaoLoginButton/KakaoLoginButton.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,7 @@
import React, { useMemo } from 'react';
import { getUUID } from '@/utils/random';

const clientId = 'f5aa2f20e42d783654b8e8c01bfc6312';
//redirectUri는 등록된 redirectUri중에 임의로 사용했습니다.
const redirectUri = 'http://localhost:5173/oauth/kakao';

const KakaoLoginButton: React.FC = () => {
const kakaoAuthUrl = useMemo(() => {
const userUUID = getUUID();
return `https://kauth.kakao.com/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&state=${userUUID}`;
}, []);

return (
<a href={kakaoAuthUrl}>
<button>카카오로 로그인</button>
</a>
);
};
const KakaoLoginButton: React.FC = () => (
<a href="/oauth/kakao">
<button>카카오로 로그인</button>
</a>
);

export default KakaoLoginButton;
11 changes: 11 additions & 0 deletions app/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createContext } from 'react';

export interface AuthStore {
isLoggedIn: boolean;
}

const AuthContext = createContext<AuthStore>({
isLoggedIn: false,
});

export default AuthContext;
38 changes: 38 additions & 0 deletions app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { RemixBrowser, useLocation, useMatches } from '@remix-run/react';
import * as Sentry from '@sentry/remix';
import { startTransition, StrictMode, useEffect } from 'react';
import { hydrateRoot } from 'react-dom/client';

Sentry.init({
dsn: import.meta.env.SHARED_SENTRY_DSN,
tracesSampleRate: 1,

integrations: [
Sentry.browserTracingIntegration({
useEffect,
useLocation,
useMatches,
}),
// https://github.com/import-js/eslint-plugin-import/issues/2969#issuecomment-1967510143
// eslint-disable-next-line import/namespace
Sentry.replayIntegration({
maskAllText: true,
blockAllMedia: true,
}),
Sentry.extraErrorDataIntegration(),
],

replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1,
environment: import.meta.env.SHARED_APP_MODE,
debug: import.meta.env.SHARED_APP_MODE === 'development',
});

startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>,
);
});
90 changes: 90 additions & 0 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type {
AppLoadContext,
EntryContext,
HandleDataRequestFunction,
} from '@remix-run/cloudflare';
import { RemixServer } from '@remix-run/react';
import * as Sentry from '@sentry/remix';
import * as isbotModule from 'isbot';
import { renderToReadableStream } from 'react-dom/server';

Sentry.init({
dsn: import.meta.env.SHARED_SENTRY_DSN,
tracesSampleRate: 1,
autoInstrumentRemix: true,
environment: import.meta.env.SHARED_APP_MODE,
debug: import.meta.env.SHARED_APP_MODE === 'development',
integrations: [Sentry.extraErrorDataIntegration()],
});

export const handleError = Sentry.sentryHandleError;

export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
loadContext: AppLoadContext,
) {
const body = await renderToReadableStream(
<RemixServer context={remixContext} url={request.url} />,
{
signal: request.signal,
onError(error: unknown) {
// Log streaming rendering errors from inside the shell
console.error(error);
responseStatusCode = 500;
},
},
);

if (isBotRequest(request.headers.get('user-agent'))) {
await body.allReady;
}

responseHeaders.set('Content-Type', 'text/html');

const cookieHeader = await loadContext.authSessionService.commitSession();
if (cookieHeader) {
responseHeaders.append('Set-Cookie', cookieHeader);
}

const response = new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});

return response;
}

export const handleDataRequest: HandleDataRequestFunction = async (
response,
{ context },
) => {
const cookieHeader = await context.authSessionService.commitSession();
if (cookieHeader) {
response.headers.append('Set-Cookie', cookieHeader);
}
return response;
};

// We have some Remix apps in the wild already running with isbot@3 so we need
// to maintain backwards compatibility even though we want new apps to use
// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev.
function isBotRequest(userAgent: string | null) {
if (!userAgent) {
return false;
}

// isbot >= 3.8.0, >4
if ('isbot' in isbotModule && typeof isbotModule.isbot === 'function') {
return isbotModule.isbot(userAgent);
}

// isbot < 3.8.0
if ('default' in isbotModule && typeof isbotModule.default === 'function') {
return isbotModule.default(userAgent);
}

return false;
}
10 changes: 10 additions & 0 deletions app/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import AuthContext from '@/contexts/AuthContext';

const useAuth = () => {
const authStore = useContext(AuthContext);

return authStore;
};

export default useAuth;
16 changes: 16 additions & 0 deletions app/hooks/useErrorToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { create } from 'zustand';
import type { JsonValue, FrontendErrorResponse } from '@/types/api';

interface ErrorToastStore {
error: FrontendErrorResponse<JsonValue> | null;
setError: (newError: FrontendErrorResponse<JsonValue>) => void;
clearError: () => void;
}

const useErrorToast = create<ErrorToastStore>()((set) => ({
error: null,
setError: (newError) => set({ error: newError }),
clearError: () => set({ error: null }),
}));

export default useErrorToast;
48 changes: 48 additions & 0 deletions app/hooks/useTypedFetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { type TypedResponse } from '@remix-run/cloudflare';
// 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 '@/types/api';

/**
* `@remix-run/react`의 `useFetcher` wrapper
* - `FrontendSuccessResponse` 또는 `FrontendErrorResponse` 형태를 반환하는 `action`만 허용
* - 에러 발생 시 에러 토스트 띄우는 로직 적용
* @example
* ```
* const Component = () => {
* const fetcher = useTypedFetcher<typeof action>();
* // ...
* };
* ```
*/
const useTypedFetcher = <
T extends (
...params: unknown[]
) => Promise<
| TypedResponse<FrontendSuccessResponse<JsonValue>>
| TypedResponse<FrontendErrorResponse<JsonValue>>
>,
>(
...params: Parameters<typeof useFetcher<T>>
) => {
const fetcher = useFetcher<T>(...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;
Loading

0 comments on commit 736df0f

Please sign in to comment.