Yuki Takei 7 месяцев назад
Родитель
Сommit
e792ba383c

+ 121 - 37
apps/app/src/pages/[[...path]]/common-helpers.ts

@@ -1,44 +1,49 @@
+import { AllLang } from '@growi/core';
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
+import type { SSRConfig } from 'next-i18next';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import { getServerSideCommonInitialProps } from '~/pages/utils/commons';
-import { getServerSidePageTitleCustomizationProps } from '~/pages/utils/page-title-customization';
-import { getServerSideUserUISettingsProps } from '~/pages/utils/user-ui-settings';
-
-import { getServerSideConfigurationProps } from './configuration-props';
+import { getLangAtServerSide } from '~/pages/utils/locale';
 
 export const NEXT_JS_ROUTING_PAGE = '[[...path]]';
 
-// Private helper function to create i18n config
-export async function createNextI18NextConfig(context: GetServerSidePropsContext, namespacesRequired?: string[]) {
+// Shared helper function to create i18n config with proper configuration
+export async function createNextI18NextConfig(
+    context: GetServerSidePropsContext,
+    namespacesRequired?: string[],
+    preloadAllLang = false,
+): Promise<SSRConfig> {
   const { serverSideTranslations } = await import('next-i18next/serverSideTranslations');
-  const lang = 'en_US';
-  const namespaces = ['commons', ...(namespacesRequired ?? ['translation'])];
-  return serverSideTranslations(lang, namespaces);
-}
 
-// Common props collection helper
-export async function collectProps(context: GetServerSidePropsContext) {
-  const propResults = await Promise.all([
-    getServerSideCommonInitialProps(context),
-    getServerSidePageTitleCustomizationProps(context),
-    getServerSideUserUISettingsProps(context),
-    getServerSideConfigurationProps(context),
-  ]);
-
-  // Validate all results have props
-  if (propResults.some(result => !('props' in result))) {
-    throw new Error('invalid getSSP result');
+  // Import configuration to fix the error
+  const nextI18NextConfig = await import('^/config/next-i18next.config');
+
+  // Determine language from request context
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const lang = getLangAtServerSide(req);
+
+  // Prepare namespaces with commons as default
+  const namespaces = ['commons'];
+  if (namespacesRequired != null) {
+    namespaces.push(...namespacesRequired);
+  }
+  else {
+    // TODO: deprecate 'translation.json' in the future
+    namespaces.push('translation');
   }
 
-  return propResults.reduce((acc, result) => ({
-    ...acc,
-    ...('props' in result ? result.props : {}),
-  }), {});
+  // Call serverSideTranslations with proper configuration
+  return serverSideTranslations(
+    lang,
+    namespaces,
+    nextI18NextConfig,
+    preloadAllLang ? AllLang : false,
+  );
 }
 
 // Common user and redirect handling
-export function handleUserAndRedirects(context: GetServerSidePropsContext, props: Record<string, unknown>) {
+type RedirectResult = { redirect: { permanent: boolean; destination: string } };
+export function handleUserAndRedirects(context: GetServerSidePropsContext, props: Record<string, unknown>): RedirectResult | null {
   const req = context.req as CrowiRequest;
   const { user } = req;
 
@@ -61,23 +66,102 @@ export function handleUserAndRedirects(context: GetServerSidePropsContext, props
   return null;
 }
 
-// Helper function to handle page data result - returns early return result or merged props
-export function handlePageDataResult<T>(
+// Helper function to handle page data result with improved type safety
+export function handlePageDataResult<T, U>(
     result: GetServerSidePropsResult<T>,
-    currentProps: Record<string, unknown>,
-): { earlyReturn: GetServerSidePropsResult<unknown> } | { mergedProps: Record<string, unknown> } {
+    currentProps: U,
+): GetServerSidePropsResult<U & T> {
   if ('redirect' in result) {
-    return { earlyReturn: result };
+    return result as GetServerSidePropsResult<U & T>;
   }
   if ('notFound' in result) {
-    return { earlyReturn: result };
+    return result as GetServerSidePropsResult<U & T>;
+  }
+
+  // Ensure result.props exists and is not a Promise
+  if (!('props' in result) || !result.props) {
+    throw new Error('Invalid page data result - missing props');
   }
 
-  // Return new merged props without side effects
+  // Type-safe props merging
   return {
-    mergedProps: {
+    props: {
       ...currentProps,
       ...result.props,
-    },
+    } as U & T,
+  };
+}
+
+// Type-safe GetServerSidePropsResult merger with overloads
+export function mergeGetServerSidePropsResults<T1, T2>(
+    result1: GetServerSidePropsResult<T1>,
+    result2: GetServerSidePropsResult<T2>,
+): GetServerSidePropsResult<T1 & T2>;
+export function mergeGetServerSidePropsResults<T1, T2, T3, T4>(
+    result1: GetServerSidePropsResult<T1>,
+    result2: GetServerSidePropsResult<T2>,
+    result3: GetServerSidePropsResult<T3>,
+    result4: GetServerSidePropsResult<T4>,
+): GetServerSidePropsResult<T1 & T2 & T3 & T4>;
+export function mergeGetServerSidePropsResults<T1, T2, T3, T4>(
+    result1: GetServerSidePropsResult<T1>,
+    result2: GetServerSidePropsResult<T2>,
+    result3?: GetServerSidePropsResult<T3>,
+    result4?: GetServerSidePropsResult<T4>,
+): GetServerSidePropsResult<T1 & T2> | GetServerSidePropsResult<T1 & T2 & T3 & T4> {
+  // Handle 2-argument case
+  if (result3 === undefined && result4 === undefined) {
+    if ('redirect' in result1) return result1;
+    if ('redirect' in result2) return result2;
+    if ('notFound' in result1) return result1;
+    if ('notFound' in result2) return result2;
+
+    if (!('props' in result1) || !('props' in result2)) {
+      throw new Error('Invalid GetServerSidePropsResult - missing props');
+    }
+
+    return {
+      props: {
+        ...result1.props,
+        ...result2.props,
+      } as T1 & T2,
+    };
+  }
+
+  // Handle 4-argument case
+  if (result3 === undefined || result4 === undefined) {
+    throw new Error('All 4 arguments required for 4-way merge');
+  }
+
+  if ('redirect' in result1) return result1;
+  if ('redirect' in result2) return result2;
+  if ('redirect' in result3) return result3;
+  if ('redirect' in result4) return result4;
+
+  if ('notFound' in result1) return result1;
+  if ('notFound' in result2) return result2;
+  if ('notFound' in result3) return result3;
+  if ('notFound' in result4) return result4;
+
+  if (!('props' in result1) || !('props' in result2)
+      || !('props' in result3) || !('props' in result4)) {
+    throw new Error('Invalid GetServerSidePropsResult - missing props');
+  }
+
+  return {
+    props: {
+      ...result1.props,
+      ...result2.props,
+      ...result3.props,
+      ...result4.props,
+    } as T1 & T2 & T3 & T4,
   };
 }
+
+// Type-safe property extraction helper
+export function extractTypedProps<T>(result: unknown, errorMessage: string): T {
+  if (typeof result !== 'object' || result === null || !('props' in result)) {
+    throw new Error(errorMessage);
+  }
+  return (result as { props: T }).props;
+}

+ 119 - 41
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -1,48 +1,108 @@
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 
-import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { addActivity } from '~/pages/utils/activity';
 import type { PageTitleCustomizationProps } from '~/pages/utils/page-title-customization';
 import { getServerSidePageTitleCustomizationProps } from '~/pages/utils/page-title-customization';
 import { getServerSideSSRProps } from '~/pages/utils/ssr';
+import type { ServerConfigurationInitialProps } from '~/states/server-configurations/hydrate';
+
+import type { CommonInitialProps, CommonEachProps } from '../utils/commons';
+import { getServerSideCommonInitialProps, getServerSideCommonEachProps } from '../utils/commons';
+import type { UserUISettingsProps } from '../utils/user-ui-settings';
+import { getServerSideUserUISettingsProps } from '../utils/user-ui-settings';
 
 import {
   NEXT_JS_ROUTING_PAGE,
-  collectProps,
   createNextI18NextConfig,
-  handleUserAndRedirects,
   handlePageDataResult,
+  mergeGetServerSidePropsResults,
 } from './common-helpers';
+import { getServerSideConfigurationProps } from './configuration-props';
 import { getPageDataForInitial, getPageDataForSameRoute } from './page-data-props';
 import type { InitialProps, SameRouteEachProps } from './types';
+import { isValidInitialAndSameRouteProps, isValidSameRouteProps } from './types';
 import { getAction } from './utils';
 
+// Common props collection helper with improved type safety
+export async function collectCombinedProps(context: GetServerSidePropsContext): Promise<
+  CommonEachProps & CommonInitialProps & PageTitleCustomizationProps & UserUISettingsProps & ServerConfigurationInitialProps
+> {
+  const propResults = await Promise.all([
+    getServerSideCommonEachProps(context),
+    getServerSideCommonInitialProps(context),
+    getServerSidePageTitleCustomizationProps(context),
+    getServerSideUserUISettingsProps(context),
+    getServerSideConfigurationProps(context),
+  ]);
+
+  // Type-safe merging of GetServerSidePropsResult (5-way merge)
+  const [commonEachResult, commonInitialResult, pageTitleResult, userUIResult, configResult] = propResults;
+
+  // First merge pairs, then combine
+  const firstPairResult = mergeGetServerSidePropsResults(commonEachResult, commonInitialResult);
+  const secondPairResult = mergeGetServerSidePropsResults(pageTitleResult, userUIResult);
+  const thirdPairResult = mergeGetServerSidePropsResults(firstPairResult, secondPairResult);
+  const finalResult = mergeGetServerSidePropsResults(thirdPairResult, configResult);
+
+  // Handle early returns (redirect/notFound)
+  if ('redirect' in finalResult || 'notFound' in finalResult) {
+    throw new Error('Unexpected redirect or notFound in props collection');
+  }
+
+  // Return the merged props
+  return finalResult.props;
+}
+
+// Type-safe helper for creating base props
+type BaseInitialProps = {
+  nextjsRoutingPage: string;
+  isNotFound: boolean;
+  isForbidden: boolean;
+  isNotCreatable: boolean;
+  isIdenticalPathPage: boolean;
+};
+
 export async function getServerSidePropsForInitial(context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<InitialProps & SameRouteEachProps>> {
-  // Collect all required props
-  const collectedProps = await collectProps(context);
+  // Collect all required props with type safety (includes CommonEachProps now)
+  const collectedProps = await collectCombinedProps(context);
+
+  // Handle redirect destination from common props
+  if (collectedProps.redirectDestination != null) {
+    return {
+      redirect: {
+        permanent: false,
+        destination: collectedProps.redirectDestination,
+      },
+    };
+  }
 
-  const props: InitialProps & SameRouteEachProps = {
-    ...collectedProps,
+  const baseProps: BaseInitialProps = {
     nextjsRoutingPage: NEXT_JS_ROUTING_PAGE,
     isNotFound: false,
     isForbidden: false,
     isNotCreatable: false,
     isIdenticalPathPage: false,
-  } as InitialProps & SameRouteEachProps;
+  };
 
-  // Handle user and redirects
-  const redirectResult = handleUserAndRedirects(context, props);
-  if (redirectResult) return redirectResult;
+  // Combine all props in a type-safe manner
+  const initialProps = {
+    ...collectedProps,
+    ...baseProps,
+  };
 
-  // Get page data
+  // Get page data with proper error handling
   const pageDataResult = await getPageDataForInitial(context);
-  const handleResult = handlePageDataResult(pageDataResult, props);
-  if ('earlyReturn' in handleResult) return handleResult.earlyReturn as GetServerSidePropsResult<InitialProps & SameRouteEachProps>;
+  const handleResult = handlePageDataResult(pageDataResult, initialProps);
 
-  // Use merged props from page data
-  const mergedProps = handleResult.mergedProps as InitialProps & SameRouteEachProps;
+  // Check for early return (redirect/notFound)
+  if ('redirect' in handleResult || 'notFound' in handleResult) {
+    return handleResult as GetServerSidePropsResult<InitialProps & SameRouteEachProps>;
+  }
 
-  // Handle SSR configuration
+  // Type assertion to access properties before full validation
+  const mergedProps = handleResult.props as InitialProps & SameRouteEachProps;
+
+  // Handle SSR configuration with type safety BEFORE validation
   if (mergedProps.pageWithMeta?.data != null) {
     const ssrPropsResult = await getServerSideSSRProps(context, mergedProps.pageWithMeta.data, ['translation']);
     if ('props' in ssrPropsResult) {
@@ -55,43 +115,61 @@ export async function getServerSidePropsForInitial(context: GetServerSidePropsCo
     mergedProps._nextI18Next = nextI18NextConfig._nextI18Next;
   }
 
+  // Ensure skipSSR is always defined
+  if (mergedProps.skipSSR === undefined) {
+    mergedProps.skipSSR = false;
+  }
+
+  // Type-safe props validation AFTER skipSSR is properly set
+  if (!isValidInitialAndSameRouteProps(mergedProps)) {
+    throw new Error('Invalid merged props structure');
+  }
+
   await addActivity(context, getAction(mergedProps));
   return { props: mergedProps };
 }
 
 export async function getServerSidePropsForSameRoute(context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<SameRouteEachProps>> {
-  // Get minimal props for same-route navigation
-  const pageTitleResult = await getServerSidePageTitleCustomizationProps(context);
-  if (!('props' in pageTitleResult)) {
-    throw new Error('invalid getSSP result');
+  // Get combined props but extract only what's needed for SameRoute
+  const collectedProps = await collectCombinedProps(context);
+
+  // Handle redirect destination from common props
+  if (collectedProps.redirectDestination != null) {
+    return {
+      redirect: {
+        permanent: false,
+        destination: collectedProps.redirectDestination,
+      },
+    };
   }
 
-  const { appTitle, customTitleTemplate } = pageTitleResult.props as PageTitleCustomizationProps;
-  const req = context.req as CrowiRequest;
-
-  const props: SameRouteEachProps = {
-    appTitle,
-    customTitleTemplate,
-    currentPathname: decodeURIComponent(context.resolvedUrl?.split('?')[0] ?? '/'),
+  // Create base props for same route navigation
+  const baseProps: SameRouteEachProps = {
+    currentPathname: collectedProps.currentPathname,
+    currentUser: collectedProps.currentUser,
+    csrfToken: collectedProps.csrfToken,
+    isMaintenanceMode: collectedProps.isMaintenanceMode,
+    redirectDestination: collectedProps.redirectDestination,
+    appTitle: collectedProps.appTitle,
+    customTitleTemplate: collectedProps.customTitleTemplate,
     nextjsRoutingPage: NEXT_JS_ROUTING_PAGE,
-    csrfToken: req.csrfToken?.() ?? '',
-    isMaintenanceMode: req.crowi.configManager.getConfig('app:isMaintenanceMode'),
     isIdenticalPathPage: false,
   };
 
-  // Handle user
-  const { user } = req;
-  if (user != null) {
-    props.currentUser = user.toObject();
-  }
-
-  // Page data retrieval
+  // Page data retrieval with improved error handling
   const sameRouteDataResult = await getPageDataForSameRoute(context);
-  const handleResult = handlePageDataResult(sameRouteDataResult, props);
-  if ('earlyReturn' in handleResult) return handleResult.earlyReturn as GetServerSidePropsResult<SameRouteEachProps>;
+  const handleResult = handlePageDataResult(sameRouteDataResult, baseProps);
 
-  // Use merged props from same route data
-  const mergedProps = handleResult.mergedProps as SameRouteEachProps;
+  // Check for early return (redirect/notFound)
+  if ('redirect' in handleResult || 'notFound' in handleResult) {
+    return handleResult as GetServerSidePropsResult<SameRouteEachProps>;
+  }
+
+  // Validate the merged props have all required properties
+  if (!isValidSameRouteProps(handleResult.props)) {
+    throw new Error('Invalid same route props structure');
+  }
+  const mergedProps = handleResult.props;
 
   return { props: mergedProps };
 }

+ 92 - 0
apps/app/src/pages/[[...path]]/types.ts

@@ -9,6 +9,10 @@ import type { PageTitleCustomizationProps } from '~/pages/utils/page-title-custo
 import type { SSRProps } from '~/pages/utils/ssr';
 import type { UserUISettingsProps } from '~/pages/utils/user-ui-settings';
 import type { PageDocument } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:pages:[[...path]]:types');
+
 
 export type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfo>;
 
@@ -68,3 +72,91 @@ export type Props = SameRouteEachProps | (InitialProps & SameRouteEachProps);
 
 // Helper types for extended props
 export type ExtendedInitialProps = InitialProps & SameRouteEachProps;
+
+/**
+ * Type guard for SameRouteEachProps validation
+ * Lightweight validation for same-route navigation
+ */
+export function isValidSameRouteProps(props: unknown): props is SameRouteEachProps {
+  logger.warn('isValidSameRouteProps');
+
+  if (typeof props !== 'object' || props === null) {
+    logger.warn('isValidSameRouteProps: props is not an object or is null');
+    return false;
+  }
+
+  const p = props as Record<string, unknown>;
+
+  // Essential properties validation
+  if (typeof p.appTitle !== 'string') {
+    logger.warn('isValidSameRouteProps: appTitle is not a string', { appTitle: p.appTitle });
+    return false;
+  }
+  if (typeof p.currentPathname !== 'string') {
+    logger.warn('isValidSameRouteProps: currentPathname is not a string', { currentPathname: p.currentPathname });
+    return false;
+  }
+  if (typeof p.csrfToken !== 'string') {
+    logger.warn('isValidSameRouteProps: csrfToken is not a string', { csrfToken: p.csrfToken });
+    return false;
+  }
+  if (typeof p.isMaintenanceMode !== 'boolean') {
+    logger.warn('isValidSameRouteProps: isMaintenanceMode is not a boolean', { isMaintenanceMode: p.isMaintenanceMode });
+    return false;
+  }
+  if (typeof p.isIdenticalPathPage !== 'boolean') {
+    logger.warn('isValidSameRouteProps: isIdenticalPathPage is not a boolean', { isIdenticalPathPage: p.isIdenticalPathPage });
+    return false;
+  }
+
+  return true;
+}
+
+/**
+ * Type guard for InitialProps & SameRouteEachProps validation
+ * First validates SameRouteEachProps, then checks InitialProps-specific properties
+ */
+export function isValidInitialAndSameRouteProps(props: unknown): props is InitialProps & SameRouteEachProps {
+  logger.warn('isValidInitialAndSameRouteProps');
+
+  // First, validate SameRouteEachProps
+  if (!isValidSameRouteProps(props)) {
+    logger.warn('isValidInitialAndSameRouteProps: SameRouteEachProps validation failed');
+    return false;
+  }
+
+  const p = props as Record<string, unknown>;
+
+  // Then validate InitialProps-specific properties
+  // CommonInitialProps
+  if (p.isNextjsRoutingTypeInitial !== true) {
+    logger.warn('isValidInitialAndSameRouteProps: isNextjsRoutingTypeInitial is not true', { isNextjsRoutingTypeInitial: p.isNextjsRoutingTypeInitial });
+    return false;
+  }
+  if (typeof p.growiVersion !== 'string') {
+    logger.warn('isValidInitialAndSameRouteProps: growiVersion is not a string', { growiVersion: p.growiVersion });
+    return false;
+  }
+
+  // SSRProps
+  if (typeof p.skipSSR !== 'boolean') {
+    logger.warn('isValidInitialAndSameRouteProps: skipSSR is not a boolean', { skipSSR: p.skipSSR });
+    return false;
+  }
+
+  // InitialProps specific page state
+  if (typeof p.isNotFound !== 'boolean') {
+    logger.warn('isValidInitialAndSameRouteProps: isNotFound is not a boolean', { isNotFound: p.isNotFound });
+    return false;
+  }
+  if (typeof p.isForbidden !== 'boolean') {
+    logger.warn('isValidInitialAndSameRouteProps: isForbidden is not a boolean', { isForbidden: p.isForbidden });
+    return false;
+  }
+  if (typeof p.isNotCreatable !== 'boolean') {
+    logger.warn('isValidInitialAndSameRouteProps: isNotCreatable is not a boolean', { isNotCreatable: p.isNotCreatable });
+    return false;
+  }
+
+  return true;
+}

+ 4 - 33
apps/app/src/pages/utils/ssr.ts

@@ -1,15 +1,11 @@
-import { AllLang } from '@growi/core';
 import { isServer } from '@growi/core/dist/utils';
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
-import type { SSRConfig, UserConfig } from 'next-i18next';
-import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
-
-import * as nextI18NextConfig from '^/config/next-i18next.config';
+import type { SSRConfig } from 'next-i18next';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { PageDocument } from '~/server/models/page';
 
-import { getLangAtServerSide } from './locale';
+import { createNextI18NextConfig } from '../[[...path]]/common-helpers';
 
 
 const skipSSR = async(page: PageDocument, ssrMaxRevisionBodyLength: number): Promise<boolean> => {
@@ -26,32 +22,6 @@ const skipSSR = async(page: PageDocument, ssrMaxRevisionBodyLength: number): Pro
   return ssrMaxRevisionBodyLength < latestRevisionBodyLength;
 };
 
-const getNextI18NextConfig = async(
-    // 'serverSideTranslations' method should be given from Next.js Page
-    //  because importing it in this file causes https://github.com/isaachinman/next-i18next/issues/1545
-    serverSideTranslations: (
-      initialLocale: string, namespacesRequired?: string[] | undefined, configOverride?: UserConfig | null, extraLocales?: string[] | false
-    ) => Promise<SSRConfig>,
-    context: GetServerSidePropsContext, namespacesRequired?: string[] | undefined, preloadAllLang = false,
-): Promise<SSRConfig> => {
-
-  // determine language
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const lang = getLangAtServerSide(req);
-
-  const namespaces = ['commons'];
-  if (namespacesRequired != null) {
-    namespaces.push(...namespacesRequired);
-  }
-  // TODO: deprecate 'translation.json' in the future
-  else {
-    namespaces.push('translation');
-  }
-
-  // The first argument must be a language code with an underscore, such as en_US
-  return serverSideTranslations(lang, namespaces, nextI18NextConfig, preloadAllLang ? AllLang : false);
-};
-
 export type SSRProps = SSRConfig & {
   skipSSR: boolean;
 }
@@ -65,7 +35,8 @@ export const getServerSideSSRProps = async(
   const { crowi } = req;
   const { configManager } = crowi;
 
-  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  // Use the shared helper function instead of the local one
+  const nextI18NextConfig = await createNextI18NextConfig(context, namespacesRequired);
 
   const ssrMaxRevisionBodyLength = configManager.getConfig('app:ssrMaxRevisionBodyLength');