♻️ refactor(migration): Next.js app router (#220)

This commit is contained in:
Arvin Xu
2023-09-25 14:33:53 +08:00
committed by GitHub
parent b2c45fa8f8
commit bb8085e707
40 changed files with 291 additions and 131 deletions
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { StyleProvider, extractStaticStyle } from 'antd-style';
import { useServerInsertedHTML } from 'next/navigation';
import { PropsWithChildren, useRef } from 'react';
const StyleRegistry = ({ children }: PropsWithChildren) => {
const isInsert = useRef(false);
useServerInsertedHTML(() => {
// avoid duplicate css insert
// refs: https://github.com/vercel/next.js/discussions/49354#discussioncomment-6279917
if (isInsert.current) return;
isInsert.current = true;
return extractStaticStyle().map((item) => item.style);
});
return <StyleProvider cache={extractStaticStyle.cache}>{children}</StyleProvider>;
};
export default StyleRegistry;
+7
View File
@@ -0,0 +1,7 @@
import Page from '@/pages/chat/mobile';
const Index = () => {
return <Page />;
};
export default Index;
+7
View File
@@ -0,0 +1,7 @@
import Page from '@/pages/chat';
const Index = () => {
return <Page />;
};
export default Index;
+7
View File
@@ -0,0 +1,7 @@
import Page from '@/pages/chat/setting';
const Index = () => {
return <Page />;
};
export default Index;
+45
View File
@@ -0,0 +1,45 @@
import { Analytics } from '@vercel/analytics/react';
import { Metadata } from 'next';
import { cookies } from 'next/headers';
import { PropsWithChildren } from 'react';
import {
LOBE_THEME_APPEARANCE,
LOBE_THEME_NEUTRAL_COLOR,
LOBE_THEME_PRIMARY_COLOR,
} from '@/const/theme';
import Layout from '@/layout/GlobalLayout';
import StyleRegistry from './StyleRegistry';
export const metadata: Metadata = {
manifest: '/manifest.json',
title: 'LobeChat',
};
const RootLayout = ({ children }: PropsWithChildren) => {
// get default theme config to use with ssr
const cookieStore = cookies();
const appearance = cookieStore.get(LOBE_THEME_APPEARANCE);
const neutralColor = cookieStore.get(LOBE_THEME_NEUTRAL_COLOR);
const primaryColor = cookieStore.get(LOBE_THEME_PRIMARY_COLOR);
return (
<html lang="en">
<body>
<StyleRegistry>
<Layout
defaultAppearance={appearance?.value}
defaultNeutralColor={neutralColor?.value as any}
defaultPrimaryColor={primaryColor?.value as any}
>
{children}
</Layout>
</StyleRegistry>
<Analytics />
</body>
</html>
);
};
export default RootLayout;
+7
View File
@@ -0,0 +1,7 @@
import Page from '@/pages/market';
const Index = () => {
return <Page />;
};
export default Index;
+7
View File
@@ -0,0 +1,7 @@
import Page from '@/pages/home';
const Index = () => {
return <Page />;
};
export default Index;
+7
View File
@@ -0,0 +1,7 @@
import Page from '@/pages/settings';
const Index = () => {
return <Page />;
};
export default Index;
+7
View File
@@ -0,0 +1,7 @@
import Page from '@/pages/welcome';
const Index = () => {
return <Page />;
};
export default Index;
+61
View File
@@ -0,0 +1,61 @@
import { NeutralColors, PrimaryColors, ThemeProvider } from '@lobehub/ui';
import { ThemeAppearance } from 'antd-style';
import { ReactNode, memo, useEffect } from 'react';
import {
LOBE_THEME_APPEARANCE,
LOBE_THEME_NEUTRAL_COLOR,
LOBE_THEME_PRIMARY_COLOR,
} from '@/const/theme';
import { useGlobalStore } from '@/store/global';
import { GlobalStyle } from '@/styles';
import { setCookie } from '@/utils/cookie';
export interface AppThemeProps {
children?: ReactNode;
defaultAppearance?: ThemeAppearance;
defaultNeutralColor?: NeutralColors;
defaultPrimaryColor?: PrimaryColors;
}
const AppTheme = memo<AppThemeProps>(
({ children, defaultAppearance, defaultPrimaryColor, defaultNeutralColor }) => {
console.log('server:appearance', defaultAppearance);
console.log('server:primaryColor', defaultPrimaryColor);
console.log('server:neutralColor', defaultNeutralColor);
const themeMode = useGlobalStore((s) => s.settings.themeMode);
const [primaryColor, neutralColor] = useGlobalStore((s) => [
s.settings.primaryColor,
s.settings.neutralColor,
]);
useEffect(() => {
console.log(primaryColor);
setCookie(LOBE_THEME_PRIMARY_COLOR, primaryColor);
}, [primaryColor]);
useEffect(() => {
setCookie(LOBE_THEME_NEUTRAL_COLOR, neutralColor);
}, [neutralColor]);
return (
<ThemeProvider
customTheme={{
neutralColor: neutralColor ?? defaultNeutralColor,
primaryColor: primaryColor ?? defaultPrimaryColor,
}}
defaultAppearance={defaultAppearance}
onAppearanceChange={(appearance) => {
setCookie(LOBE_THEME_APPEARANCE, appearance);
}}
themeMode={themeMode}
>
<GlobalStyle />
{children}
</ThemeProvider>
);
},
);
export default AppTheme;
-2
View File
@@ -13,9 +13,7 @@ export const DEFAULT_BASE_SETTINGS: GlobalBaseSettings = {
avatar: '',
fontSize: 14,
language: 'zh-CN',
neutralColor: '',
password: '',
primaryColor: '',
themeMode: 'auto',
};
+5
View File
@@ -0,0 +1,5 @@
export const LOBE_THEME_APPEARANCE = 'LOBE_THEME_APPEARANCE';
export const LOBE_THEME_PRIMARY_COLOR = 'LOBE_THEME_PRIMARY_COLOR';
export const LOBE_THEME_NEUTRAL_COLOR = 'LOBE_THEME_NEUTRAL_COLOR';
+4 -3
View File
@@ -1,7 +1,7 @@
import { Icon, MobileTabBar, type MobileTabBarProps } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Bot, MessageSquare } from 'lucide-react';
import Router from 'next/router';
import { useRouter } from 'next/navigation';
import { rgba } from 'polished';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -20,6 +20,7 @@ export default memo<{ className?: string }>(({ className }) => {
const [tab, setTab] = useGlobalStore((s) => [s.sidebarKey, s.switchSideBar]);
const { t } = useTranslation('common');
const { styles } = useStyles();
const router = useRouter();
const items: MobileTabBarProps['items'] = useMemo(
() => [
{
@@ -28,7 +29,7 @@ export default memo<{ className?: string }>(({ className }) => {
),
key: 'chat',
onClick: () => {
Router.push('/chat');
router.push('/chat');
},
title: t('tab.chat'),
},
@@ -36,7 +37,7 @@ export default memo<{ className?: string }>(({ className }) => {
icon: (active) => <Icon className={active ? styles.active : undefined} icon={Bot} />,
key: 'market',
onClick: () => {
Router.push({ hash: '', pathname: `/market` });
router.push('/market', { hash: '' });
},
title: t('tab.market'),
},
+3 -2
View File
@@ -10,7 +10,7 @@ import {
Settings,
Settings2,
} from 'lucide-react';
import Router from 'next/router';
import { useRouter } from 'next/navigation';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -25,6 +25,7 @@ export interface BottomActionProps {
}
const BottomActions = memo<BottomActionProps>(({ tab, setTab }) => {
const router = useRouter();
const { t } = useTranslation('common');
const { exportSessions, exportSettings, exportAll, exportAgents } = useExportConfig();
@@ -101,7 +102,7 @@ const BottomActions = memo<BottomActionProps>(({ tab, setTab }) => {
label: t('setting'),
onClick: () => {
setTab('settings');
Router.push('/settings');
router.push('/settings');
},
},
],
+6 -4
View File
@@ -1,6 +1,6 @@
import { ActionIcon } from '@lobehub/ui';
import { Bot, MessageSquare } from 'lucide-react';
import Router from 'next/router';
import { usePathname, useRouter } from 'next/navigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -13,6 +13,8 @@ export interface TopActionProps {
}
const TopActions = memo<TopActionProps>(({ tab, setTab }) => {
const pathname = usePathname();
const router = useRouter();
const { t } = useTranslation('common');
const switchBackToChat = useSessionStore((s) => s.switchBackToChat);
return (
@@ -22,7 +24,7 @@ const TopActions = memo<TopActionProps>(({ tab, setTab }) => {
icon={MessageSquare}
onClick={() => {
// 如果已经在 chat 路径下了,那么就不用再跳转了
if (Router.asPath.startsWith('/chat')) return;
if (pathname?.startsWith('/chat')) return;
switchBackToChat();
setTab('chat');
}}
@@ -34,8 +36,8 @@ const TopActions = memo<TopActionProps>(({ tab, setTab }) => {
active={tab === 'market'}
icon={Bot}
onClick={() => {
if (Router.asPath.startsWith('/market')) return;
Router.push('/market');
if (pathname?.startsWith('/market')) return;
router.push('/market');
setTab('market');
}}
placement={'right'}
@@ -1,16 +1,17 @@
import { ThemeProvider, lobeCustomTheme } from '@lobehub/ui';
'use client';
import { App, ConfigProvider } from 'antd';
import { useThemeMode } from 'antd-style';
import 'antd/dist/reset.css';
import Zh_CN from 'antd/locale/zh_CN';
import { changeLanguage } from 'i18next';
import { PropsWithChildren, memo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PropsWithChildren, memo, useEffect } from 'react';
import AppTheme, { AppThemeProps } from '@/components/AppTheme';
import { createI18nNext } from '@/locales/create';
import { useGlobalStore, useOnFinishHydrationGlobal } from '@/store/global';
import { usePluginStore } from '@/store/plugin';
import { useOnFinishHydrationSession, useSessionStore } from '@/store/session';
import { GlobalStyle } from '@/styles';
import { useStyles } from './style';
@@ -19,14 +20,26 @@ const i18n = createI18nNext();
const Layout = memo<PropsWithChildren>(({ children }) => {
const { styles } = useStyles();
const router = useRouter();
useEffect(() => {
// refs: https://github.com/pmndrs/zustand/blob/main/docs/integrations/persisting-store-data.md#hashydrated
useSessionStore.persist.rehydrate();
useGlobalStore.persist.rehydrate();
usePluginStore.persist.rehydrate();
}, []);
useOnFinishHydrationGlobal((state) => {
i18n.then(() => {
changeLanguage(state.settings.language);
});
});
useOnFinishHydrationSession((s) => {
useOnFinishHydrationSession((s, store) => {
usePluginStore.getState().checkLocalEnabledPlugins(s.sessions);
// add router instance to store
store.setState({ router });
});
return (
@@ -36,31 +49,10 @@ const Layout = memo<PropsWithChildren>(({ children }) => {
);
});
export default memo(({ children }: PropsWithChildren) => {
useEffect(() => {
// refs: https://github.com/pmndrs/zustand/blob/main/docs/integrations/persisting-store-data.md#hashydrated
useSessionStore.persist.rehydrate();
useGlobalStore.persist.rehydrate();
usePluginStore.persist.rehydrate();
}, []);
const ThemeWrapper = ({ children, ...theme }: AppThemeProps) => (
<AppTheme {...theme}>
<Layout>{children}</Layout>
</AppTheme>
);
const themeMode = useGlobalStore((s) => s.settings.themeMode);
const [primaryColor, neutralColor] = useGlobalStore((s) => [
s.settings.primaryColor,
s.settings.neutralColor,
]);
const { browserPrefers } = useThemeMode();
const isDarkMode = themeMode === 'auto' ? browserPrefers === 'dark' : themeMode === 'dark';
const genCustomToken: any = useCallback(
() => lobeCustomTheme({ isDarkMode, neutralColor, primaryColor }),
[primaryColor, neutralColor, isDarkMode],
);
return (
<ThemeProvider customToken={genCustomToken || {}} themeMode={themeMode}>
<GlobalStyle />
<Layout>{children}</Layout>
</ThemeProvider>
);
});
export default ThemeWrapper;
-11
View File
@@ -1,11 +0,0 @@
import { Analytics } from '@vercel/analytics/react';
import type { AppProps } from 'next/app';
import Layout from '@/layout';
export default ({ Component, pageProps }: AppProps) => (
<Layout>
<Component {...pageProps} />
<Analytics />
</Layout>
);
-46
View File
@@ -1,46 +0,0 @@
import { Meta } from '@lobehub/ui';
import { StyleProvider, extractStaticStyle } from 'antd-style';
import Document, { DocumentContext, Head, Html, Main, NextScript } from 'next/document';
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const page = await ctx.renderPage({
enhanceApp: (App) => (props) => (
<StyleProvider cache={extractStaticStyle.cache}>
<App {...props} />
</StyleProvider>
),
});
const styles = extractStaticStyle(page.html).map((item) => item.style);
const initialProps = await Document.getInitialProps(ctx);
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{styles}
</>
),
};
}
render() {
return (
<Html>
<Head>
<Meta title={'LobeChat'} />
<link href="/manifest.json" rel="manifest" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
+3 -2
View File
@@ -2,7 +2,7 @@ import { SiOpenai } from '@icons-pack/react-simple-icons';
import { ActionIcon, Avatar, ChatHeader, ChatHeaderTitle, Tag } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { PanelRightClose, PanelRightOpen, Settings } from 'lucide-react';
import Router from 'next/router';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
@@ -16,6 +16,7 @@ import ShareButton from './ShareButton';
const Header = memo(() => {
const init = useSessionChatInit();
const router = useRouter();
const { t } = useTranslation('common');
@@ -80,7 +81,7 @@ const Header = memo(() => {
<ActionIcon
icon={Settings}
onClick={() => {
Router.push({ hash: location.hash, pathname: `/chat/setting` });
router.push('/chat/settings', { hash: location.hash });
}}
size={{ fontSize: 24 }}
title={t('header.session', { ns: 'setting' })}
+4 -3
View File
@@ -1,6 +1,6 @@
import { ActionIcon, MobileNavBar, MobileNavBarTitle } from '@lobehub/ui';
import { LayoutList, Settings } from 'lucide-react';
import Router from 'next/router';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,6 +10,7 @@ import { agentSelectors, sessionSelectors } from '@/store/session/selectors';
const MobileHeader = memo(() => {
const { t } = useTranslation('common');
const router = useRouter();
const [isInbox, title, model] = useSessionStore((s) => [
sessionSelectors.isInboxSession(s),
@@ -24,7 +25,7 @@ const MobileHeader = memo(() => {
return (
<MobileNavBar
center={<MobileNavBarTitle desc={model} title={displayTitle} />}
onBackClick={() => Router.push({ hash: null, pathname: `/chat` })}
onBackClick={() => router.push('/chat', { hash: null })}
right={
<>
<ActionIcon icon={LayoutList} onClick={() => toggleConfig()} />
@@ -32,7 +33,7 @@ const MobileHeader = memo(() => {
<ActionIcon
icon={Settings}
onClick={() => {
Router.push({ hash: location.hash, pathname: `/chat/setting` });
router.push('/chat/settings', { hash: location.hash });
}}
/>
)}
@@ -1,7 +1,7 @@
import { ActionIcon, Logo, MobileNavBar } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { MessageSquarePlus, Settings2 } from 'lucide-react';
import Router from 'next/router';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import AvatarWithUpload from '@/features/AvatarWithUpload';
@@ -19,6 +19,7 @@ export const useStyles = createStyles(({ css, token }) => ({
const Header = memo(() => {
const [createSession] = useSessionStore((s) => [s.createSession]);
const router = useRouter();
return (
<MobileNavBar
@@ -30,7 +31,7 @@ const Header = memo(() => {
<ActionIcon
icon={Settings2}
onClick={() => {
Router.push({ pathname: `/settings` });
router.push('/settings');
}}
/>
</>
@@ -1,3 +1,5 @@
'use client';
import { useResponsive } from 'antd-style';
import Head from 'next/head';
import { memo } from 'react';
@@ -1,3 +1,5 @@
'use client';
import Head from 'next/head';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
@@ -1,15 +1,16 @@
import { ChatHeader, ChatHeaderTitle } from '@lobehub/ui';
import Router from 'next/router';
import { useRouter } from 'next/navigation';
import { ReactNode, memo } from 'react';
import { useTranslation } from 'react-i18next';
const Header = memo<{ children: ReactNode }>(({ children }) => {
const { t } = useTranslation('setting');
const router = useRouter();
return (
<ChatHeader
left={<ChatHeaderTitle title={t('header.session')} />}
onBackClick={() => Router.push({ hash: location.hash, pathname: `/chat` })}
onBackClick={() => router.push('/chat', { hash: location.hash })}
right={children}
showBackButton
/>
@@ -1,15 +1,16 @@
import { MobileNavBar, MobileNavBarTitle } from '@lobehub/ui';
import Router from 'next/router';
import { useRouter } from 'next/navigation';
import { type ReactNode, memo } from 'react';
import { useTranslation } from 'react-i18next';
const Header = memo<{ children: ReactNode }>(({ children }) => {
const { t } = useTranslation('setting');
const router = useRouter();
return (
<MobileNavBar
center={<MobileNavBarTitle title={t('header.session')} />}
onBackClick={() => Router.push({ hash: location.hash, pathname: `/chat/mobile` })}
onBackClick={() => router.push('/chat/mobile', { hash: location.hash })}
right={children}
showBackButton
/>
@@ -1,3 +1,5 @@
'use client';
import { useResponsive } from 'antd-style';
import isEqual from 'fast-deep-equal';
import Head from 'next/head';
@@ -1,10 +1,16 @@
import { memo, useEffect } from 'react';
'use client';
import { useSessionHydrated, useSessionStore } from '@/store/session';
import { memo } from 'react';
import Loading from '@/pages/home/Loading';
import {
useEffectAfterSessionHydrated,
useSessionHydrated,
useSessionStore,
} from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import Loading from './index/Loading';
import Welcome from './welcome/index.page';
import Welcome from '../welcome';
const Home = memo(() => {
const hydrated = useSessionHydrated();
@@ -13,7 +19,7 @@ const Home = memo(() => {
s.switchSession,
]);
useEffect(() => {
useEffectAfterSessionHydrated(() => {
if (hasSession) switchSession();
}, [hasSession]);
+4 -2
View File
@@ -1,11 +1,13 @@
import { ActionIcon, Logo, MobileNavBar } from '@lobehub/ui';
import { Settings2 } from 'lucide-react';
import Router from 'next/router';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import AvatarWithUpload from '@/features/AvatarWithUpload';
const Header = memo(() => {
const router = useRouter();
return (
<MobileNavBar
center={<Logo type={'text'} />}
@@ -14,7 +16,7 @@ const Header = memo(() => {
<ActionIcon
icon={Settings2}
onClick={() => {
Router.push({ pathname: `/settings` });
router.push('/settings');
}}
/>
}
@@ -1,3 +1,5 @@
'use client';
import { useResponsive } from 'antd-style';
import Head from 'next/head';
import { memo, useEffect } from 'react';
@@ -1,15 +1,16 @@
import { MobileNavBar, MobileNavBarTitle } from '@lobehub/ui';
import Router from 'next/router';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const Header = memo(() => {
const { t } = useTranslation('setting');
const router = useRouter();
return (
<MobileNavBar
center={<MobileNavBarTitle title={t('header.global')} />}
onBackClick={() => Router.push('/chat')}
onBackClick={() => router.push('/chat')}
showBackButton
/>
);
@@ -1,3 +1,5 @@
'use client';
import { useResponsive } from 'antd-style';
import Head from 'next/head';
import { memo } from 'react';
@@ -1,3 +1,5 @@
'use client';
import { useResponsive } from 'antd-style';
import Head from 'next/head';
import { memo } from 'react';
@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { StoreApi, UseBoundStore } from 'zustand';
import { SessionStore, useSessionStore } from '../store';
@@ -6,11 +7,13 @@ import { SessionStore, useSessionStore } from '../store';
* 当 Session 水合完毕后才会执行的 useEffect
* @param fn
*/
export const useOnFinishHydrationSession = (fn: (state: SessionStore) => void) => {
export const useOnFinishHydrationSession = (
fn: (state: SessionStore, store: UseBoundStore<StoreApi<SessionStore>>) => void,
) => {
useEffect(() => {
// 只有当水合完毕后再开始做操作
useSessionStore.persist.onFinishHydration(() => {
fn(useSessionStore.getState());
fn(useSessionStore.getState(), useSessionStore);
});
}, []);
};
+4 -5
View File
@@ -1,6 +1,5 @@
import { produce } from 'immer';
import { merge } from 'lodash-es';
import Router from 'next/router';
import { DeepPartial } from 'utility-types';
import { StateCreator } from 'zustand/vanilla';
@@ -154,22 +153,22 @@ export const createSessionSlice: StateCreator<
},
switchBackToChat: () => {
const { activeId } = get();
const { activeId, router } = get();
const id = activeId || INBOX_SESSION_ID;
get().activeSession(id);
Router.push(SESSION_CHAT_URL(id, get().isMobile));
router?.push(SESSION_CHAT_URL(id, get().isMobile));
},
switchSession: (sessionId = INBOX_SESSION_ID) => {
const { isMobile } = get();
const { isMobile, router } = get();
// mobile also should switch session due to chat mobile route is different
// fix https://github.com/lobehub/lobe-chat/issues/163
if (!isMobile && get().activeId === sessionId) return;
get().activeSession(sessionId);
Router.push(SESSION_CHAT_URL(sessionId, isMobile));
router?.push(SESSION_CHAT_URL(sessionId, isMobile));
},
});
@@ -1,4 +1,5 @@
import { merge } from 'lodash-es';
import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context';
import { DEFAULT_AGENT_META, DEFAULT_INBOX_AVATAR } from '@/const/meta';
import { LobeAgentConfig, LobeAgentSession, LobeSessionType } from '@/types/session';
@@ -14,6 +15,7 @@ export interface SessionState {
// 默认会话
inbox: LobeAgentSession;
isMobile?: boolean;
router?: AppRouterInstance;
searchKeywords: string;
sessions: Record<string, LobeAgentSession>;
topicSearchKeywords: string;
+2 -2
View File
@@ -37,9 +37,9 @@ export interface GlobalBaseSettings {
*/
historyCount?: number;
language: Locales;
neutralColor: NeutralColors | '';
neutralColor?: NeutralColors;
password: string;
primaryColor: PrimaryColors | '';
primaryColor?: PrimaryColors;
themeMode: ThemeMode;
}
+4
View File
@@ -0,0 +1,4 @@
export const setCookie = (key: string, value: string | undefined) => {
// eslint-disable-next-line unicorn/no-document-cookie
document.cookie = `${key}=${value};`;
};
+8 -2
View File
@@ -19,7 +19,12 @@
"types": ["vitest/globals"],
"paths": {
"@/*": ["./src/*"]
}
},
"plugins": [
{
"name": "next"
}
]
},
"exclude": ["node_modules"],
"include": [
@@ -29,7 +34,8 @@
"tests",
"**/*.ts",
"**/*.d.ts",
"**/*.tsx"
"**/*.tsx",
".next/types/**/*.ts"
],
"ts-node": {
"compilerOptions": {