Просмотр исходного кода

Merge pull request #10824 from growilabs/claude/typescript-nextjs-config-pdjvs

support: Typescriptize Next.js configuration files
Yuki Takei 1 месяц назад
Родитель
Сommit
dc1077fbd9

+ 25 - 31
apps/app/next.config.js → apps/app/next.config.ts

@@ -5,16 +5,23 @@
  * See: https://github.com/vercel/next.js/discussions/35969#discussioncomment-2522954
  */
 
-const path = require('node:path');
-
-const {
+import type { NextConfig } from 'next';
+import {
   PHASE_PRODUCTION_BUILD,
   PHASE_PRODUCTION_SERVER,
-} = require('next/constants');
+} from 'next/constants';
+import path from 'node:path';
+import bundleAnalyzer from '@next/bundle-analyzer';
+
+import nextI18nConfig from './config/next-i18next.config';
+import {
+  createChunkModuleStatsPlugin,
+  listPrefixedPackages,
+} from './src/utils/next.config.utils';
 
-const getTranspilePackages = () => {
-  const { listPrefixedPackages } = require('./src/utils/next.config.utils');
+const { i18n, localePath } = nextI18nConfig;
 
+const getTranspilePackages = (): string[] => {
   const packages = [
     // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
     'react-markdown',
@@ -68,18 +75,10 @@ const getTranspilePackages = () => {
     ]),
   ];
 
-  // const eazyLogger = require('eazy-logger');
-  // const logger = eazyLogger.Logger({
-  //   prefix: '[{green:next.config.js}] ',
-  //   useLevelPrefixes: false,
-  // });
-  // logger.info('{bold:Listing scoped packages for transpiling:}');
-  // logger.unprefixed('info', `{grey:${JSON.stringify(packages, null, 2)}}`);
-
   return packages;
 };
 
-const optimizePackageImports = [
+const optimizePackageImports: string[] = [
   '@growi/core',
   '@growi/editor',
   '@growi/pluginkit',
@@ -93,11 +92,9 @@ const optimizePackageImports = [
   '@growi/ui',
 ];
 
-module.exports = (phase) => {
-  const { i18n, localePath } = require('./config/next-i18next.config');
-
+export default (phase: string): NextConfig => {
   /** @type {import('next').NextConfig} */
-  const nextConfig = {
+  const nextConfig: NextConfig = {
     reactStrictMode: true,
     poweredByHeader: false,
     pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
@@ -121,23 +118,22 @@ module.exports = (phase) => {
       optimizePackageImports,
     },
 
-    /** @param config {import('next').NextConfig} */
     webpack(config, options) {
       // Auto-wrap getServerSideProps with superjson serialization (replaces next-superjson SWC plugin)
       if (options.isServer) {
-        config.module.rules.push({
+        config.module!.rules!.push({
           test: /\.page\.(tsx|ts)$/,
-          use: [path.resolve(__dirname, 'src/utils/superjson-ssr-loader.js')],
+          use: [path.resolve(__dirname, 'src/utils/superjson-ssr-loader.ts')],
         });
       }
 
       if (!options.isServer) {
         // Avoid "Module not found: Can't resolve 'fs'"
         // See: https://stackoverflow.com/a/68511591
-        config.resolve.fallback.fs = false;
+        config.resolve!.fallback = { ...config.resolve!.fallback, fs: false };
 
         // exclude packages from the output bundles
-        config.module.rules.push(
+        config.module!.rules!.push(
           ...[
             /dtrace-provider/,
             /mongoose/,
@@ -157,7 +153,7 @@ module.exports = (phase) => {
 
       // extract sourcemap
       if (options.dev) {
-        config.module.rules.push({
+        config.module!.rules!.push({
           test: /.(c|m)?js$/,
           exclude: [/node_modules/, path.resolve(__dirname)],
           enforce: 'pre',
@@ -168,15 +164,13 @@ module.exports = (phase) => {
       // setup i18next-hmr
       if (!options.isServer && options.dev) {
         const { I18NextHMRPlugin } = require('i18next-hmr/webpack');
-        config.plugins.push(new I18NextHMRPlugin({ localesDir: localePath }));
+        config.plugins!.push(new I18NextHMRPlugin({ localesDir: localePath }));
       }
 
       // Log eager vs lazy module counts for dev compilation analysis
       if (!options.isServer && options.dev) {
-        const {
-          createChunkModuleStatsPlugin,
-        } = require('./src/utils/next.config.utils');
-        config.plugins.push(createChunkModuleStatsPlugin());
+        // biome-ignore lint/suspicious/noExplicitAny: webpack plugin type compatibility
+        config.plugins!.push(createChunkModuleStatsPlugin() as any);
       }
 
       return config;
@@ -188,7 +182,7 @@ module.exports = (phase) => {
     return nextConfig;
   }
 
-  const withBundleAnalyzer = require('@next/bundle-analyzer')({
+  const withBundleAnalyzer = bundleAnalyzer({
     enabled:
       phase === PHASE_PRODUCTION_BUILD &&
       (process.env.ANALYZE === 'true' || process.env.ANALYZE === '1'),

+ 8 - 0
apps/app/src/server/crowi/index.ts

@@ -554,8 +554,16 @@ class Crowi {
     await this.buildServer();
 
     // setup Next.js
+    // Save ts-node's .ts extension hook before Next.js prepare() destroys it.
+    // Next.js 15's next.config.ts transpiler registers/deregisters its own require hooks,
+    // and deregisterHook() deletes require.extensions['.ts'] instead of restoring the previous hook.
+    const savedTsHook = require.extensions['.ts'];
     this.nextApp = next({ dev });
     await this.nextApp.prepare();
+    // Restore ts-node's .ts hook if Next.js removed it
+    if (savedTsHook && !require.extensions['.ts']) {
+      require.extensions['.ts'] = savedTsHook;
+    }
 
     // setup CrowiDev
     if (dev) {

+ 3 - 1
apps/app/src/server/util/project-dir-utils.ts

@@ -3,7 +3,9 @@ import path from 'node:path';
 import process from 'node:process';
 import { isServer } from '@growi/core/dist/utils/browser-utils';
 
-const isCurrentDirRoot = isServer() && fs.existsSync('./next.config.js');
+const isCurrentDirRoot =
+  isServer() &&
+  (fs.existsSync('./next.config.ts') || fs.existsSync('./next.config.js'));
 
 export const projectRoot = isCurrentDirRoot
   ? process.cwd()

+ 46 - 29
apps/app/src/utils/next.config.utils.js → apps/app/src/utils/next.config.utils.ts

@@ -1,26 +1,24 @@
 // workaround by https://github.com/martpie/next-transpile-modules/issues/143#issuecomment-817467144
 
-const fs = require('node:fs');
-const path = require('node:path');
+import fs from 'node:fs';
+import path from 'node:path';
 
 const nodeModulesPaths = [
   path.resolve(__dirname, '../../node_modules'),
   path.resolve(__dirname, '../../../../node_modules'),
 ];
 
-/**
- * @typedef { { ignorePackageNames: string[] } } Opts
- */
+interface Opts {
+  ignorePackageNames: string[];
+}
 
-/** @type {Opts} */
-const defaultOpts = { ignorePackageNames: [] };
+const defaultOpts: Opts = { ignorePackageNames: [] };
 
-/**
- * @param scopes {string[]}
- */
-exports.listScopedPackages = (scopes, opts = defaultOpts) => {
-  /** @type {string[]} */
-  const scopedPackages = [];
+export const listScopedPackages = (
+  scopes: string[],
+  opts: Opts = defaultOpts,
+): string[] => {
+  const scopedPackages: string[] = [];
 
   nodeModulesPaths.forEach((nodeModulesPath) => {
     fs.readdirSync(nodeModulesPath)
@@ -36,7 +34,9 @@ exports.listScopedPackages = (scopes, opts = defaultOpts) => {
               'package.json',
             );
             if (fs.existsSync(packageJsonPath)) {
-              const { name } = require(packageJsonPath);
+              const { name } = JSON.parse(
+                fs.readFileSync(packageJsonPath, 'utf-8'),
+              ) as { name: string };
               if (!opts.ignorePackageNames.includes(name)) {
                 scopedPackages.push(name);
               }
@@ -48,19 +48,25 @@ exports.listScopedPackages = (scopes, opts = defaultOpts) => {
   return scopedPackages;
 };
 
-/**
- * @param prefixes {string[]}
- */
+type WebpackCompiler = {
+  outputPath: string;
+  hooks: {
+    done: {
+      tap(name: string, callback: (stats: any) => void): void;
+    };
+  };
+};
+
 /**
  * Webpack plugin that logs eager (initial) vs lazy (async-only) module counts.
  * Attach to client-side dev builds only.
  */
-exports.createChunkModuleStatsPlugin = () => ({
-  apply(compiler) {
+export const createChunkModuleStatsPlugin = () => ({
+  apply(compiler: WebpackCompiler) {
     compiler.hooks.done.tap('ChunkModuleStatsPlugin', (stats) => {
       const { compilation } = stats;
-      const initialModuleIds = new Set();
-      const asyncModuleIds = new Set();
+      const initialModuleIds = new Set<string>();
+      const asyncModuleIds = new Set<string>();
 
       for (const chunk of compilation.chunks) {
         const target = chunk.canBeInitial() ? initialModuleIds : asyncModuleIds;
@@ -90,9 +96,13 @@ exports.createChunkModuleStatsPlugin = () => ({
           (id) => !initialModuleIds.has(id),
         );
 
-        const analyzeModuleSet = (moduleIds, title, filename) => {
-          const packageCounts = {};
-          const appModules = [];
+        const analyzeModuleSet = (
+          moduleIds: Set<string> | string[],
+          title: string,
+          filename: string,
+        ): void => {
+          const packageCounts: Record<string, number> = {};
+          const appModules: string[] = [];
           for (const rawId of moduleIds) {
             // Strip webpack loader prefixes (e.g., "source-map-loader!/path/to/file" → "/path/to/file")
             const id = rawId.includes('!')
@@ -113,7 +123,10 @@ exports.createChunkModuleStatsPlugin = () => ({
             (a, b) => b[1] - a[1],
           );
           const lines = [`# ${title}`, ''];
-          lines.push(`Total modules: ${moduleIds.length ?? moduleIds.size}`);
+          const totalCount = Array.isArray(moduleIds)
+            ? moduleIds.length
+            : moduleIds.size;
+          lines.push(`Total modules: ${totalCount}`);
           lines.push(`App modules (non-node_modules): ${appModules.length}`);
           lines.push(`node_modules packages: ${sorted.length}`);
           lines.push('');
@@ -152,9 +165,11 @@ exports.createChunkModuleStatsPlugin = () => ({
   },
 });
 
-exports.listPrefixedPackages = (prefixes, opts = defaultOpts) => {
-  /** @type {string[]} */
-  const prefixedPackages = [];
+export const listPrefixedPackages = (
+  prefixes: string[],
+  opts: Opts = defaultOpts,
+): string[] => {
+  const prefixedPackages: string[] = [];
 
   nodeModulesPaths.forEach((nodeModulesPath) => {
     fs.readdirSync(nodeModulesPath)
@@ -167,7 +182,9 @@ exports.listPrefixedPackages = (prefixes, opts = defaultOpts) => {
           'package.json',
         );
         if (fs.existsSync(packageJsonPath)) {
-          const { name } = require(packageJsonPath);
+          const { name } = JSON.parse(
+            fs.readFileSync(packageJsonPath, 'utf-8'),
+          ) as { name: string };
           if (!opts.ignorePackageNames.includes(name)) {
             prefixedPackages.push(name);
           }

+ 5 - 2
apps/app/src/utils/superjson-ssr-loader.js → apps/app/src/utils/superjson-ssr-loader.ts

@@ -11,7 +11,7 @@
  *   const __getServerSideProps__: ... = async (ctx) => { ... };
  *   export const getServerSideProps = __withSuperJSONProps__(__getServerSideProps__);
  */
-module.exports = function superjsonSsrLoader(source) {
+function superjsonSsrLoader(source: string): string {
   if (!/export\s+const\s+getServerSideProps\b/.test(source)) {
     return source;
   }
@@ -29,4 +29,7 @@ module.exports = function superjsonSsrLoader(source) {
     renamed +
     '\nexport const getServerSideProps = __withSuperJSONProps__(__getServerSideProps__);\n'
   );
-};
+}
+
+// biome-ignore lint/style/noDefaultExport: webpack loaders require a default export
+export default superjsonSsrLoader;