Skip to content

支持云函数Typescript模板 #9

Open
@zsnmwy

Description

@zsnmwy

问题

  1. 数据模型的TS提示,在含JS源码的情况下,问题多多
  2. 开发云函数时,缺乏对event事件入参的明确定义,譬如 定时器事件、HTTP事件

需求

需要一个能够支持云函数开发的Typescript模板并且该模板能有对应的提示词来辅助AI开发。

自有项目的部分Code分享

build ts

这里并没有使用常规的打包工具,而是使用bun直接编译typescript文件以及对js文件做后处理以支持 Nodejs18+ 正常使用 ES Module

#!/usr/bin/env node

const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');

/**
 * 使用bun编译TypeScript文件
 * 使用目录级别编译,保持模块引用关系和目录结构
 */

const sourceDir = 'functions';
const targetDir = 'dist';

function findFunctionDirs(dir) {
  const functionDirs = [];
  const items = fs.readdirSync(dir);

  for (const item of items) {
    const fullPath = path.join(dir, item);
    const stat = fs.statSync(fullPath);

    if (stat.isDirectory() && item !== 'node_modules' && item !== 'dist') {
      // 检查是否包含TypeScript文件
      if (hasTypeScriptFiles(fullPath)) {
        functionDirs.push({
          name: item,
          path: fullPath,
          outputPath: path.join(targetDir, item)
        });
      }
    }
  }

  return functionDirs;
}

function hasTypeScriptFiles(dir) {
  try {
    const items = fs.readdirSync(dir);
    for (const item of items) {
      const fullPath = path.join(dir, item);
      const stat = fs.statSync(fullPath);

      if (stat.isFile() && (item.endsWith('.ts') || item.endsWith('.tsx'))) {
        return true;
      } else if (stat.isDirectory() && item !== 'node_modules' && item !== 'dist') {
        if (hasTypeScriptFiles(fullPath)) {
          return true;
        }
      }
    }
  } catch (error) {
    // 忽略权限错误等
  }
  return false;
}

function ensureDir(dirPath) {
  if (!fs.existsSync(dirPath)) {
    fs.mkdirSync(dirPath, { recursive: true });
  }
}

/**
 * 后处理转译后的文件,添加 .mjs 扩展名到相对路径的 import 语句
 * @param {string} dirPath 目录路径
 * @param {string} functionOutputPath 函数输出根目录
 */
function postProcessImportsInDir(dirPath, functionOutputPath) {
  const items = fs.readdirSync(dirPath);

  for (const item of items) {
    const fullPath = path.join(dirPath, item);
    const stat = fs.statSync(fullPath);

    if (stat.isDirectory() && item !== 'node_modules') {
      postProcessImportsInDir(fullPath, functionOutputPath);
    } else if (stat.isFile() && item.endsWith('.mjs')) {
      postProcessImports(fullPath, functionOutputPath);
    }
  }
}

/**
 * 检查目标路径是否存在对应的index文件
 * @param {string} importPath 导入路径
 * @param {string} currentFileDir 当前文件所在目录
 * @param {string} functionOutputPath 函数输出目录
 * @returns {string} 修正后的导入路径
 */
function resolveImportPath(importPath, currentFileDir, functionOutputPath) {
  // 只处理相对路径
  if (!importPath.startsWith('./') && !importPath.startsWith('../')) {
    return importPath;
  }

  // 计算绝对路径
  const absolutePath = path.resolve(currentFileDir, importPath);

  // 检查是否存在对应的index.mjs文件
  const indexPath = path.join(absolutePath, 'index.mjs');
  if (fs.existsSync(indexPath)) {
    // 如果存在index.mjs,则指向index.mjs
    const indexRelativePath = path.relative(currentFileDir, indexPath);
    return './' + indexRelativePath.replace(/\\/g, '/');
  }

  // 检查是否存在直接的.mjs文件
  const directPath = absolutePath + '.mjs';
  if (fs.existsSync(directPath)) {
    return importPath + '.mjs';
  }

  // 默认添加.mjs扩展名
  return importPath + '.mjs';
}

/**
 * 后处理单个文件的import语句
 * @param {string} filePath 文件路径
 * @param {string} functionOutputPath 函数输出目录
 */
function postProcessImports(filePath, functionOutputPath) {
  try {
    let content = fs.readFileSync(filePath, 'utf8');
    const currentFileDir = path.dirname(filePath);

    // 1. 将 .js 扩展名替换为 .mjs(针对相对路径)
    content = content.replace(
      /import\s+([^'"]*)\s+from\s+['"](\.[^'"]*?)\.js['"]/g,
      'import $1 from "$2.mjs"'
    );

    content = content.replace(
      /export\s+([^'"]*)\s+from\s+['"](\.[^'"]*?)\.js['"]/g,
      'export $1 from "$2.mjs"'
    );

    // 2. 智能处理没有扩展名的相对路径
    content = content.replace(
      /import\s+([^'"]*)\s+from\s+['"](\.[^'"]*?)(?<!\.mjs)(?<!\.ts)(?<!\.tsx)(?<!\.json)['"]/g,
      (_, imports, importPath) => {
        const resolvedPath = resolveImportPath(importPath, currentFileDir, functionOutputPath);
        return `import ${imports} from "${resolvedPath}"`;
      }
    );

    content = content.replace(
      /export\s+([^'"]*)\s+from\s+['"](\.[^'"]*?)(?<!\.mjs)(?<!\.ts)(?<!\.tsx)(?<!\.json)['"]/g,
      (_, exports, importPath) => {
        const resolvedPath = resolveImportPath(importPath, currentFileDir, functionOutputPath);
        return `export ${exports} from "${resolvedPath}"`;
      }
    );

    fs.writeFileSync(filePath, content, 'utf8');
    console.log(`  ✓ Post-processed imports in ${filePath}`);
  } catch (error) {
    console.warn(`  ⚠ Warning: Could not post-process imports in ${filePath}:`, error.message);
  }
}

/**
 * 查找目录中的所有TypeScript文件
 * @param {string} dir 目录路径
 * @param {string[]} files 文件列表
 * @returns {string[]} TypeScript文件路径列表
 */
function findTsFilesInDir(dir, files = []) {
  const items = fs.readdirSync(dir);

  for (const item of items) {
    const fullPath = path.join(dir, item);
    const stat = fs.statSync(fullPath);

    if (stat.isDirectory() && item !== 'node_modules' && item !== 'dist') {
      findTsFilesInDir(fullPath, files);
    } else if (stat.isFile() && (item.endsWith('.ts') || item.endsWith('.tsx'))) {
      files.push(fullPath);
    }
  }

  return files;
}

async function buildFunctionDir(functionDir) {
  // 查找该函数目录中的所有TypeScript文件
  const tsFiles = findTsFilesInDir(functionDir.path);

  if (tsFiles.length === 0) {
    console.log(`No TypeScript files found in ${functionDir.name}`);
    return;
  }

  console.log(`Building function directory: ${functionDir.name} (${tsFiles.length} files)`);

  // 并行编译该目录中的所有TypeScript文件
  const buildPromises = tsFiles.map(tsFile => buildSingleFile(tsFile, functionDir));

  try {
    await Promise.all(buildPromises);
    console.log(`✓ Built function directory: ${functionDir.name}`);

    // 后处理:添加 .mjs 扩展名到相对路径的 import 语句
    postProcessImportsInDir(functionDir.outputPath, functionDir.outputPath);
  } catch (error) {
    console.error(`✗ Failed to build function directory ${functionDir.name}:`, error.message);
    throw error;
  }
}

async function buildSingleFile(tsFile, functionDir) {
  return new Promise((resolve, reject) => {
    // 计算相对于函数目录的路径
    const relativePath = path.relative(functionDir.path, tsFile);
    const outputPath = path.join(functionDir.outputPath, relativePath.replace(/\.ts$/, '.mjs'));
    const outputDir = path.dirname(outputPath);

    // 确保输出目录存在
    ensureDir(outputDir);

    // 构建bun命令参数
    const buildArgs = [
      'build',
      tsFile,
      '--outfile',
      outputPath,
      '--target',
      'node',
      '--format',
      'esm',
      '--no-bundle'  // 关键选项:只转译,不打包
    ];

    // 使用bun build进行转译
    const buildProcess = spawn('bun', buildArgs, {
      stdio: 'pipe'
    });

    let stdout = '';
    let stderr = '';

    buildProcess.stdout.on('data', (data) => {
      stdout += data.toString();
    });

    buildProcess.stderr.on('data', (data) => {
      stderr += data.toString();
    });

    buildProcess.on('close', (code) => {
      if (code === 0) {
        console.log(`  ✓ ${relativePath} -> ${path.relative(functionDir.outputPath, outputPath)}`);
        resolve();
      } else {
        console.error(`  ✗ Failed to build ${relativePath}:`);
        if (stderr) console.error(stderr);
        if (stdout) console.log(stdout);
        reject(new Error(`Build failed for ${relativePath}`));
      }
    });
  });
}

async function main() {
  console.log('Building TypeScript files using directory-level compilation...');

  // 确保目标目录存在
  ensureDir(targetDir);

  // 查找所有包含TypeScript文件的函数目录
  const functionDirs = findFunctionDirs(sourceDir);

  if (functionDirs.length === 0) {
    console.log('No function directories with TypeScript files found.');
    return;
  }

  console.log(`Found ${functionDirs.length} function directories:`);
  functionDirs.forEach(dir => console.log(`  - ${dir.name} (${dir.path})`));

  // 并行编译所有函数目录
  try {
    await Promise.all(functionDirs.map(buildFunctionDir));
    console.log('✓ All function directories built successfully!');
  } catch (error) {
    console.error('✗ Build failed:', error.message);
    process.exit(1);
  }
}

main().catch(console.error);

Copy Assets

#!/usr/bin/env node

const fs = require('fs');
const path = require('path');

/**
 * 复制非TypeScript文件到dist目录
 * 保持目录结构不变,排除node_modules
 */

const sourceDir = 'functions';
const targetDir = 'dist';

// 需要复制的文件扩展名
const copyExtensions = ['.json', '.yaml', '.yml', '.md', '.txt', '.js', '.mjs'];

// 需要排除的目录
const excludeDirs = ['node_modules', 'dist', '.git'];

function shouldCopyFile(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  return copyExtensions.includes(ext);
}

function shouldSkipDir(dirName) {
  return excludeDirs.includes(dirName);
}

function ensureDir(dirPath) {
  if (!fs.existsSync(dirPath)) {
    fs.mkdirSync(dirPath, { recursive: true });
  }
}

function copyFile(src, dest) {
  ensureDir(path.dirname(dest));
  fs.copyFileSync(src, dest);
  console.log(`Copied: ${src} -> ${dest}`);
}

function copyAssetsRecursive(srcDir, destDir) {
  const items = fs.readdirSync(srcDir);
  
  for (const item of items) {
    const srcPath = path.join(srcDir, item);
    const destPath = path.join(destDir, item);
    const stat = fs.statSync(srcPath);
    
    if (stat.isDirectory()) {
      if (!shouldSkipDir(item)) {
        ensureDir(destPath);
        copyAssetsRecursive(srcPath, destPath);
      }
    } else if (stat.isFile()) {
      if (shouldCopyFile(srcPath)) {
        copyFile(srcPath, destPath);
      }
    }
  }
}

// 确保目标目录存在
ensureDir(targetDir);

// 开始复制
console.log('Copying assets from functions to dist...');
copyAssetsRecursive(sourceDir, targetDir);
console.log('Asset copying completed.');

Global Types

可能有缺或不正确的地方

/**
 * 全局云函数类型定义
 * 这些类型在所有云函数中都可以直接使用,无需import
 */

declare global {
    /**
     * 云函数HTTP事件类型
     * @template T body的类型,默认为string
     * @template R queryStringParameters的类型,默认为Record<string, string>
     * @template H headers的类型,默认为Record<string, string>
     */
    interface CloudFunctionEvent<T = string, R = Record<string, string>, H = Record<string, string>> {
        body: T
        headers: H
        httpMethod: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" | string
        isBase64Encoded: boolean
        multiValueHeaders: Record<string, string[]>
        path: string
        queryStringParameters: R
        requestContext: {
            appId: string
            envId: string
            requestId: string
            uin: string
        }
    }

    /**
     * 云函数定时任务事件类型
     */
    interface CloudFunctionScheduleEvent {
        TriggerName: string
    }

    /**
     * 云函数上下文类型
     */
    interface CloudFunctionContext {
        requestId: string
        functionName: string
        functionVersion: string
        memoryLimitInMB: number
        timeRemaining: number
        [key: string]: any
    }

    /**
     * 通用API响应类型
     * @template T 响应数据的类型
     */
    interface ApiResponse<T = any> {
        code: number
        message?: string
        data?: T
        error?: string
        timestamp?: string
    }

    /**
     * 云函数主入口函数类型
     * @template T 事件body的类型
     * @template R 响应数据的类型
     */
    type CloudFunctionMain<T = any, R = any> = (
        event: CloudFunctionEvent<T>,
        context: CloudFunctionContext
    ) => Promise<ApiResponse<R>>

    /**
     * 数据库查询结果类型
     * @template T 数据项的类型
     */
    interface DatabaseQueryResult<T = any> {
        data: T[]
        total?: number
        limit?: number
        offset?: number
    }

    /**
     * 分页参数类型
     */
    interface PaginationParams {
        page?: number
        pageSize?: number
        limit?: number
        offset?: number
    }

    /**
     * 排序参数类型
     */
    interface SortParams {
        sortBy?: string
        sortOrder?: 'asc' | 'desc' | 'ASC' | 'DESC'
    }

    /**
     * 通用查询参数类型
     */
    interface QueryParams extends PaginationParams, SortParams {
        [key: string]: any
    }
}

// 确保这个文件被视为模块
export { }

TSConfig

{
  "compilerOptions": {
    // Environment setup & latest features
    "lib": [
      "ESNext"
    ],
    "target": "ESNext",
    "module": "Preserve",
    "moduleDetection": "force",
    "allowJs": true,
    // Bundler mode for better module resolution
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    // Output configuration (for reference, actual output handled by bun)
    "outDir": "./dist",
    "rootDir": "./functions",
    // Best practices
    "strict": true,
    "skipLibCheck": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "forceConsistentCasingInFileNames": true,
    // Module interop
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    // Development features
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "removeComments": false,
    "preserveConstEnums": true,
    // Decorators (if needed)
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    // Type definitions
    "types": [
      "node",
      "node-fetch"
    ]
  },
  "include": [
    "functions/**/*",
    "global.d.ts"
  ],
  "exclude": [
    "functions/**/node_modules",
    "functions/**/dist",
    "dist"
  ]
}

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions