首页
/ Astro 单体仓库开发约束与常见陷阱:Node.js API 边界、测试隔离与虚拟模块规范

Astro 单体仓库开发约束与常见陷阱:Node.js API 边界、测试隔离与虚拟模块规范

2026-09-05 10:10:22作者:柯茵沙

本文基于 Astro 仓库内的开发者技能文档 constraints.md 展开,系统讲解在 packages/astro 中贡献代码时必须遵守的核心约束:多运行时环境下的 Node.js API 使用边界、构建期与运行期的代码隔离、测试隔离、虚拟模块命名、包边界与性能限制。读完后,你将能够判断一段代码应该放在哪里、哪些 API 可以安全使用,并了解这些约束是如何被 Biome 等工具链强制执行的。

一、最重要的约束:Node.js API 使用限制

为什么存在这个约束

Astro 的运行时产物需要在多种环境中执行:Node.js(传统托管)、Cloudflare Workers(V8 isolate)、Deno、以及各种边缘运行时(API 受限)。因此 packages/astro 中的代码对 Node.js API 的使用被严格限制,这是文档中标注为 "MOST IMPORTANT CONSTRAINT" 的第一规则。

该约束在 CONTRIBUTING.md 的 "Naming convention and APIs usage" 一节中也有明确说明:Astro 代码可能运行在非 Node.js 环境中(如 Cloudflare Workers),因此应该把运行时无关(runtime-agnostic)的代码放入名为 runtime 的文件夹或文件中(runtime/runtime.ts)。

保守的安全决策规则

文档给出的三步判断法:

1. Vite 插件实现中 → 允许使用 Node.js API
2. /runtime/ 文件夹中 → 禁止使用 Node.js API
3. 其他位置 → 避免使用 Node.js API,改用 @astrojs/internal-helpers

文档同时诚实地指出:代码库的现实并不总是边界清晰,最安全的做法是——除非你在写 Vite 插件,否则一律避免 Node.js API。

绝对禁止的位置(由 Biome 强制执行)

禁止规则通过 Biome linter 的 noNodejsModules 规则在 lint 阶段强制执行。查看仓库根目录的 biome.jsonc(第 137–150 行的 overrides 配置),可以看到与文档描述完全一致的模式匹配:

{
	// We don't want to have node modules in code that should be runtime agnostic
	"includes": [
		"**/packages/astro/src/**/runtime/**/*.ts",
		"**/packages/astro/src/**/*runtime*.ts"
	],
	"linter": {
		"rules": {
			"correctness": {
				"noNodejsModules": "error"
			}
		}
	}
}

即两类文件绝对禁止 node: 前缀的导入:

  1. 任何位于 runtime/ 文件夹中的文件:**/packages/astro/src/**/runtime/**/*.ts
  2. 文件名中含 runtime 的任何文件:**/packages/astro/src/**/*runtime*.ts

典型的禁止位置示例:

  • packages/astro/src/runtime/server/ → 禁止(runtime 文件夹)
  • packages/astro/src/runtime/client/ → 禁止(runtime 文件夹)
  • packages/astro/src/vite-plugin-astro/runtime.ts → 禁止(文件名含 runtime)
  • packages/astro/src/core/render/runtime-utils.ts → 禁止(文件名含 runtime)

在这些位置中,以下导入都是被禁止的:

// FORBIDDEN in runtime/ folders or *runtime*.ts files
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import process from 'node:process';
import crypto from 'node:crypto';
// etc.

Node.js API 安全的位置

  • packages/astro/src/vite-plugin-*/ → Vite 插件实现,安全
  • packages/astro/src/cli/ → CLI 代码,安全
  • 测试文件 → 安全
  • packages/astro/src/core/混合区域(同时包含构建期与运行时代码),需要谨慎

core/ 的安全做法:

// AVOID in core/ unless in Vite plugin
import fs from 'node:fs';

// PREFER cross-platform utilities
import { fileURLToPath } from '@astrojs/internal-helpers/path';

这里的跨平台工具来自工作区内的 @astrojs/internal-helpers 包。从 path.ts 的源码结构看,该模块提供了一组与文件系统解耦的路径字符串工具,例如 joinPaths()(用正斜杠安全拼接路径段)、collapseDuplicateSlashes()trimSlashes()isInternalPath() 等,覆盖 Astro 核心与各集成项目中常见的路径处理场景。具体导出的 API 名称以仓库当前代码为准,写代码前建议先浏览该文件。

特例:Vite 插件的"内外有别"

这是最容易被误解的边界。Vite 插件实现本身运行在 Node.js 中,可以使用 Node.js API;但插件返回的虚拟模块代码会在 runtime/server 上下文中执行,不能使用 Node.js API:

// ALLOWED:插件实现本身
export function myVitePlugin() {
  return {
    name: 'my-plugin',
    load: {
      filter: { id: /\.astro$/ },
      async handler(id) {
        // 这里可以用 Node.js API
        const fs = await import('node:fs/promises');
        const content = await fs.readFile(id, 'utf-8');
        return { code: content };
      },
    },
  };
}
// FORBIDDEN:虚拟模块的输出代码
export function myVitePlugin() {
  return {
    name: 'my-plugin',
    load: {
      filter: { id: new RegExp(`^\\0virtual:my-module$`) },
      handler() {
        // 这段代码在 runtime/server 上下文执行
        return {
          code: `
            import fs from 'node:fs';  // FORBIDDEN
            export const data = fs.readFileSync('/data.json');
          `,
        };
      },
    },
  };
}

这一规则同样被 CONTRIBUTING.md 明确重申:"You can use Node.js APIs inside the implementation of the vite plugins, but if the vite plugin returns a virtual module, that virtual module can't use Node.js APIs."

需要注意:NonRunnableDevEnvironment

某些适配器(如 Cloudflare)会为 Vite 配置 NonRunnableDevEnvironment,这会给 Vite 插件的能力加上限制。在这种环境中,插件的 hook(transform、load 等)仍然会执行,但某些操作(例如 runner.import())不可用。

仓库中可以看到对应实现:dev-nonrunnable.ts 定义了名为 dev-nonrunnable 的非可运行开发环境,并在 vite-plugin-head/index.ts 的注释中被引用,说明多个插件的实现必须兼容这一受限环境。推论很直接:不要依赖完整的 Vite 运行时能力,编写运行时无关的代码是本质要求。

违规后的表现

如果违反了这一约束,你通常会看到:

  • ReferenceError: fs is not defined
  • ReferenceError: process is not defined
  • 或者更隐蔽的:构建成功,但在边缘环境运行时失败

预防措施是代码评审 + 在多种环境中测试。

二、跨平台代码的三个处理模式

文档给出了三种推荐的替代方案:

模式 1:使用跨平台工具

避免直接调用 Node.js API,改用 @astrojs/internal-helpers 提供的工具:

// BAD: Direct Node.js API
import { resolve } from 'node:path';
const fullPath = resolve('./config.json');

// GOOD: Cross-platform utility
import { fileURLToPath } from '@astrojs/internal-helpers/path';
const fullPath = fileURLToPath(new URL('./config.json', import.meta.url));

模式 2:文件操作放进 Vite 插件

确实需要文件操作时,把它放到 Vite 插件实现中(那里 Node.js API 是安全的):

// GOOD: In Vite plugin implementation
export function myVitePlugin() {
  return {
    name: 'my-plugin',
    load: {
      filter: { id: /\.config\.js$/ },
      async handler(id) {
        // 这里安全
        const fs = await import('node:fs/promises');
        const content = await fs.readFile(id, 'utf-8');
        return { code: content };
      },
    },
  };
}

模式 3:构建期数据生成 + 虚拟模块嵌入

需要读取数据时,在构建期通过 Vite 插件读取,把数据直接嵌入生成的代码中(而非在运行时读取):

// Vite plugin - 插件实现中安全
const VIRTUAL_MODULE_ID = 'virtual:my-config';
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;

export function myVitePlugin() {
  return {
    name: 'my-plugin',
    resolveId: {
      filter: { id: new RegExp(`^${VIRTUAL_MODULE_ID}$`) },
      handler() {
        return RESOLVED_VIRTUAL_MODULE_ID;
      },
    },
    load: {
      filter: { id: new RegExp(`^${RESOLVED_VIRTUAL_MODULE_ID}$`) },
      async handler() {
        // 构建期读取(这里安全)
        const fs = await import('node:fs/promises');
        const config = JSON.parse(await fs.readFile('./config.json', 'utf-8'));
        // 嵌入到生成代码中(输出不含 Node.js API)
        return {
          code: `export default ${JSON.stringify(config)}`,
        };
      },
    },
  };
}

这个"构建期读数据、运行期只用序列化结果"的思路正是 Astro 大量核心数据的流通方式,下一节展开。

三、构建期与运行期的边界

边界划分表

代码必须尊重执行上下文边界,文档给出的完整对照:

上下文 位置 何时执行 Node API
Build core/ astro build 可以
Dev core/ astro dev 启动设置阶段 可以
Runtime Server runtime/server/ SSR 渲染时 不可以
Runtime Client runtime/client/ 浏览器中 不可以

常见违规与正确模式

典型错误是运行时代码直接 import 构建期代码:

// BAD: Runtime code importing build code
// In runtime/server/render.ts
import { buildSomething } from '../../core/build/utils.js';

问题在于:运行期不应依赖构建期代码,这会造成耦合并增大运行时 bundle 体积。正确做法是让数据从构建期流向运行期,通过 manifest(清单)传递:

// GOOD: Data flows from build to runtime via manifest
// Build time (core/build/)
const manifest = {
  routes: processedRoutes,
  config: runtimeConfig,
};

// Runtime (runtime/server/)
import { manifest } from 'virtual:astro:manifest';

仓库中该模式有真实对应:manifest 虚拟模块serialized.ts 负责构建期将路由等数据序列化进 virtual:astro:manifest,供运行期消费。

四、循环依赖:类型集中管理

问题

TypeScript 循环依赖会导致:运行时导出为 undefined、类型解析问题、构建失败。最常见于类型导入——A 的 types 从 B 的实现文件导入,B 又反过来导入 A。

解决方案:类型集中放在 packages/astro/src/types/

// BAD: Import type from implementation
import type { AstroConfig } from '../config/index.js';

// GOOD: Import type from types/
import type { AstroConfig } from '../types/public.js';

预防措施有三条:

  • 类型一律从 types/ 目录导入
  • 不要为了实现而导入实现文件取类型
  • 仅用于类型的导入使用 import type(而非 import

Biome 配置也配套强制了这一点:biome.jsoncuseImportTypeuseExportType 均为 error 级别,注释说明其目的是"避免把不需要的代码打进 bundle"。

五、虚拟模块命名约定

约束:新的虚拟模块必须使用 virtual:astro:* 前缀,而不是旧式的 @astro-page:*(遗留命名)。

原因:遵循 Rollup/Vite 社区通用约定,避免模块 ID 冲突。

从仓库实际使用情况看,这一约定被严格执行:virtual:astro: 前缀在 packages/astro/src 下遍布数十个文件,覆盖 manifest、env、actions、head、pages、routes 等核心机制,例如 vite-plugin-pagesvite-plugin-environmentvite-plugin-adapter-config。完整的虚拟模块注册表、实现模式与详细约定,参见同目录的 architecture.md

六、测试隔离要求

每个测试 fixture 必须拥有唯一的 outDir,以避免测试之间的缓存污染。

原因在于:构建产物会被缓存,并通过 ESM 在测试运行之间共享——若两个测试共用输出目录,前一个测试的产物可能被后一个测试误读,产生难以定位的偶发失败。详细的解释、示例与检测策略参见同目录的 testing.md

七、包边界:workspace 依赖与 catalog

fixture 必须使用 workspace 依赖

测试 fixture 和示例项目必须使用 workspace:* 协议:

// GOOD: fixture package.json
{
  "dependencies": {
    "astro": "workspace:*",
    "@astrojs/react": "workspace:*"
  }
}
// BAD: specific versions
{
  "dependencies": {
    "astro": "^4.0.0",
    "@astrojs/react": "^3.0.0"
  }
}

原因:开发期间链接到本地包、自动使用最新本地代码、防止版本不一致。CONTRIBUTING.md 中"Testing your changes"一节也印证了这一点:/examples 下的示例都链接到本地 Astro 源码,可以直接 pnpm --filter @example/minimal run dev 观察改动效果。

外部依赖使用 catalog

对外部包,在有 catalog 可用时应使用 catalog: 协议:

{
  "dependencies": {
    "astro": "workspace:*",
    "react": "catalog:",
    "react-dom": "catalog:"
  }
}

文档指出 catalog 位置在仓库根的 package.json,即所有 fixture 共用一份版本声明,保证依赖版本在整个工作区内一致。

八、Changeset 要求

何时需要 changeset

需要

  • packages/ 下的所有包
  • 任何用户可感知的变更

不需要

  • examples/ 中的示例
  • 测试 fixture
  • 纯文档变更
  • 无 API 变化的内部重构

Prerelease 模式

文档提醒:仓库可能处于 prerelease 模式,需检查 .changeset/config.json 中的 baseBranch 字段(文档以 "baseBranch": "origin/next" 为例,意味着变更会进入 next 发布通道而非 latest)。仓库中的 .changeset/config.json 实际还包含 changelog 生成器配置(@changesets/changelog-github)、access: "public"updateInternalDependencies: "patch" 等字段——提交前以仓库当前配置为准判断发布通道。

创建 changeset

pnpm exec changeset

选择受影响的包并描述变更即可。

九、性能约束

构建缓存

构建产物缓存在 .astro/dist/ 目录中,是共享缓存。推论:

  • 测试必须使用唯一 outDir(与第六节呼应)
  • 有时需要手动清缓存
  • 缓存既能加速,也会引发问题(陈旧产物)

Content Layer 的串行执行

内容加载的并发控制位于 content-layer.ts,源码确认了文档描述的 PQueue 模式:

this.#queue = new PQueue({ concurrency: 1 });

含义:Content Layer 的 loader 串行执行而非并行。原因是防止数据仓库(data store)中的竞态条件。如果你在做内容加载相关的工作,不要假设 loader 是并发的,也不要在 loader 内部做耗时操作却期望它能被并发分摊。

大站点的内存限制

大型站点构建时可能触及 Node.js 堆内存上限,缓解方式是手动提升 --max-old-space-size

node --max-old-space-size=4096 node_modules/.bin/astro build

十、调试约束

CI 中禁止交互式命令

CI 环境没有 TTY,因此以下交互式命令被禁止:

# BAD: Interactive
git rebase -i
git add -i

日志级别

生产代码默认不应输出冗长日志。仓库的 Biome 规则配套了约束:biome.jsoncnoConsole 规则(warn 级别)只允许 errorwarninfodebug 方法,而裸 console.log 会被标记;对 packages/integrations/**/*.ts 则进一步收紧到 error 级别。正确写法:

// BAD: Always logs
console.log('Processing file:', file);

// GOOD: Use logger with levels
logger.debug('Processing file:', file);

十一、常见陷阱清单

1. 测试中的文件路径

// BAD: Relative to cwd
const fixture = await loadFixture({
  root: 'fixtures/my-test/',
});

// GOOD: Relative to test file
const fixture = await loadFixture({
  root: './fixtures/my-test/',
});

关键区别在开头的 ./——保证路径相对于测试文件解析,而非当前工作目录(vitest 的 cwd 可能不同)。

2. Hooks 中忘记 await

// BAD: Forgetting await
before(async () => {
  fixture.build(); // Missing await
});

// GOOD: Proper await
before(async () => {
  await fixture.build();
});

3. 服务器未清理

// BAD: Server not stopped
describe('dev', () => {
  before(async () => {
    devServer = await fixture.startDevServer();
  });
  // Missing after() hook
});

// GOOD: Always cleanup
describe('dev', () => {
  before(async () => {
    devServer = await fixture.startDevServer();
  });
  after(async () => {
    await devServer.stop();
  });
});

未停止的 dev server 会占用端口、持有文件句柄,污染后续测试乃至 CI 环境。

4. 类型导入方式

// BAD: Runtime import for types
import { AstroConfig } from 'astro';

// GOOD: Type-only import
import type { AstroConfig } from 'astro';

5. 模块解析必须显式扩展名

// BAD: May fail in different contexts
import helper from '../utils';

// GOOD: Explicit extension
import helper from '../utils.js';

ESM 环境下省略扩展名会在某些上下文中解析失败,packages/astro/src 内部导入一律带 .js 扩展名。

十二、提交前自检清单

在提交代码前,逐项核对:

  • [ ] runtime/ 代码中没有 Node.js API
  • [ ] 测试使用唯一的 outDir
  • [ ] 无循环依赖
  • [ ] 虚拟模块遵循 virtual:astro:* 命名
  • [ ] fixture 使用 workspace 依赖
  • [ ] 已创建 changeset(如需要)
  • [ ] 测试中 server 已清理
  • [ ] 类型导入使用 import type
  • [ ] 文件导入显式带 .js 扩展名

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐