第02章 - 技术栈与工程结构

2.1 版本基线与锁版本纪律

这个项目的技术栈不是「挑最新」,而是「挑一套互相验证过、且能被 CI 固定下来的组合」。根 package.json 把版本全部写成精确值(没有一个 ^),并且用 packageManager 锁定包管理器:

{
  "name": "mathmindforge",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@11.22.0",
  "devDependencies": {
    "typescript": "5.9.3",
    "vite": "8.3.4",
    "vitest": "5.0.4",
    "vue-tsc": "3.3.13",
    "eslint": "10.12.0",
    "typescript-eslint": "8.71.2",
    "eslint-plugin-vue": "10.11.1",
    "@playwright/test": "1.64.0",
    "tailwindcss": "4.3.3",
    "@tailwindcss/vite": "4.3.3",
    "tsx": "4.23.15"
  }
}

完整的基线(与 docs/02-技术架构.md §1 一致,本仓库实测版本):

层 选型 版本 说明
运行时 Node.js 24.x CI 与本地一致
包管理 pnpm workspace 11.22.0 packageManager 锁定
语言 TypeScript 5.9.3(strict) 全仓 strict,含 .vue
应用外壳 Vue 3 + Pinia 3.5.44 / 4.0.3 工坊、星图、工具台、报告
样式 Tailwind CSS 4.3.3(@tailwindcss/vite) 设计 token 走 @theme
构建 Vite 8.3.4 每关独立 chunk;Phaser 独立 vendor chunk
画布引擎 Phaser 4.2.1(裁剪版) 只出现在需要画布的关卡 chunk
内容校验 zod 4.6.7 构建期校验,不进首屏
单测 Vitest 5.0.4 Node 环境,纯逻辑
端到端 Playwright 1.64.0 单套配置,视口 1024×768
Lint eslint / typescript-eslint / eslint-plugin-vue 10.12.0 / 8.71.2 / 10.11.1 flat config + 架构约束规则
类型检查 vue-tsc 3.3.13 --noEmit
脚本运行器 tsx 4.23.15 内容体检 / 审计 CLI

有两件事这张表没写,但必须知道:

  1. 原生壳的版本是「计划」不是「现状」。 docs/02 里列的 Tauri CLI 2.12.2 与 Capacitor 8.5.3 目前没有任何工程文件落地——仓库里只有 apps/site,apps/desktop 与 apps/mobile 尚未创建。写需求时不要把「已支持 EXE/APK」当成事实。
  2. Phaser 是裁剪版 vendor,不是 npm 依赖的完整包。 packages/templates 与 packages/canvas-kit 的 package.json 里写着 "phaser": "4.2.1",但构建时 Vite 的 alias 会把它换成交付在仓库里的 packages/canvas-kit/vendor/phaser-trim.esm.js(见 2.6)。

至于为什么要把版本钉到这个程度,理由不是「怕新东西」,而是三条具体的工程后果:

第一,门禁要可比。 pnpm verify 的输出要能在 CI 上复现。类型检查、lint 规则集、单测、体积极限全都依赖工具版本;版本漂移会让「昨天绿今天红」变成常态,而这份项目把七个门禁并行、体积门禁串行地串成一条 pnpm verify(见第 03 章 §3.6),漂移的代价是整条链失去诊断价值。

第二,体积预算是硬编码的。 scripts/check-size.mjs 里写着 initialKb: 85、chunkKb: 280、distKb: 4000。打包器换一个小版本就可能改变 chunk 切分与压缩结果,首屏 69.5 KB 这种数字一旦不明原因地跳变,预算就不再是预算。

第三,玩法代码与工具链要分开评审。 docs/02 §1 的版本纪律说得直接:升级任何一个版本都单独一个 PR,跑全量 pnpm verify + e2e + size;不允许「顺手升级」混在玩法 PR 里。这条纪律和「内容 PR 与引擎 PR 分开」是同一种思路:把「行为变更」与「依赖变更」拆开,出问题时才能二分。

需要特别留意的是 packageManager 字段的作用:它不是注释,pnpm 会据此校验并切换到指定版本(配合 Corepack)。所以 clone 之后不需要在文档里写「请安装 pnpm 11.22.0」——写错版本会直接被拒。

2.2 pnpm workspace 布局与 exports 直连源码

workspace 只有三行,但决定了整个仓库的目录语义:

packages:
  - 'apps/*'
  - 'packages/*'
  - 'games/*'

allowBuilds:
  esbuild: true
  '@tailwindcss/oxide': true
  sharp: true
  • apps/*:可独立启动的应用(当前只有 apps/site);
  • packages/*:库(契约、内容、模板、工具、画布、证据、存档、平台、i18n);
  • games/*:玩法包(当前只有 games/forge,一个包承载 20 关)。

allowBuilds 是 pnpm 11 对「安装期执行脚本」的白名单:只放行 esbuild、Tailwind 的 oxide 原生绑定与 sharp,其余依赖不允许在 install 时跑任意脚本。这是供应链安全的最小配置。

最关键的工程选择:包没有构建步骤,exports 直接指向 .ts 源码。

// packages/content/package.json
{
  "name": "@mmf/content",
  "type": "module",
  "exports": {
    ".": "./src/index.ts",
    "./ids": "./src/ids.ts",
    "./full": "./src/full.ts"
  },
  "dependencies": { "zod": "4.6.7" }
}

packages/templates 把这条思路推到极致——它把一个包拆成二十多个子路径:

"exports": {
  ".": "./src/index.ts",
  "./core": "./src/core.ts",
  "./layout": "./src/layout.ts",
  "./shared": "./src/shared/index.ts",
  "./pattern": "./src/pattern/index.ts",
  "./pattern.logic": "./src/pattern/logic.ts",
  "./pattern.runtime": "./src/pattern/runtime.ts",
  "./rule": "./src/rule/index.ts",
  "./schemas": "./src/schemas.ts",
  "./verifiers": "./src/verifiers.ts",
  "./argue.runtime": "./src/argue/runtime.ts"
}

这样做的收益与代价都很具体:

收益 代价
没有「改一行要重建库」的循环,改完源码刷新即生效 消费方必须走 exports 声明过的路径,不能深层相对导入
子路径即代码分割点:一个关卡只 import 自己那个模板的 runtime exports 表要手写维护,漏一条就只能在类型层面用到它
测试与 CLI(tsx / Vitest)读的是同一份源码 需要打包器与 TS 都能解析 .ts 的 exports(由 bundler 解析模式解决,见 2.4)

为什么这件事对体积是决定性的:@mmf/templates 里放着六个模板的逻辑、schema 与运行时代码。如果所有关卡都从 barrel 里 import,打包器会把六个模板一起放进共享 chunk;现在每关从 @mmf/templates/<template>.runtime 取自己那一份,@mmf/templates 主 barrel 只留给 Node 侧测试与类型引用。同一招在 @mmf/content 上是三条出口:.(外壳用,含摘要与 loadLevel)、./ids(只要常量的地方)、./full(只给 CLI 与测试,含 zod schema 与全部详情)。

三条出口的纪律由 lint 兜住:apps/site/src/** 里 import @mmf/content/full 直接报错,因为一句 import 就能让「详情懒加载」作废,而体积门禁要到打包后才发现。

2.3 每个包的职责与依赖方向

包的划分不是按「文件多少」切,而是按能不能脱离浏览器跑切。下表按依赖顺序从底往上排:

包 职责 依赖
@mmf/content id 常量、20 关摘要与详情 JSON、zod schema、loadLevel、localize zod
@mmf/game-kit 契约:LevelModule / LevelContext / LevelRuntime / LevelAction / Verdict / RunSnapshot / 存档与证据类型 / PlatformServices 接口 @mmf/content(仅类型与常量)
@mmf/toolkit 纯逻辑:模式规则、种子 RNG、统计(Wilson 区间等) content、game-kit
@mmf/evidence 证据聚合、四维画像、徽章规则、轨迹、报告与本地化(纯函数) content、game-kit
@mmf/progress ProgressManager、存档、迁移、日志 content、game-kit、evidence
@mmf/platform Web 实现:IndexedDB、音频、TTS、文件、偏好、生命周期 game-kit、progress
@mmf/i18n 界面文案(zh 常驻 + en 表)、Translator / t(key, params) content
@mmf/canvas-kit Phaser 启动器、BaseScene、主题、绘制助手、裁剪版 vendor game-kit、phaser
@mmf/templates 六个模板:schema / logic / runtime / scene / layout content、game-kit、toolkit、canvas-kit、i18n、zod、phaser
@mmf/toolkit-ui Vue:ToolDock、六件工具面板、论证板 game-kit、toolkit、i18n、vue
@mmf/game-forge 20 个关卡入口(一关一个子路径导出) content、game-kit、templates
@mmf/site 唯一产品入口:外壳、路由、视图、stores 上面几乎全部 + vue、pinia

依赖图(docs/02 §3.1,只允许箭头方向):

apps/site ──→ game-forge(registry 懒加载), toolkit-ui, evidence, progress, platform, i18n, content, game-kit
apps/site ──→ canvas-kit:只通过 game-forge 的异步关卡 chunk 间接使用;外壳本身不 import Phaser
game-forge ──→ templates, content, game-kit
templates  ──→ toolkit, canvas-kit, game-kit, content/ids, i18n, zod
toolkit-ui ──→ toolkit, game-kit, i18n, vue
canvas-kit ──→ game-kit, i18n, phaser
toolkit    ──→ game-kit, content/ids
evidence   ──→ game-kit, content/ids
progress   ──→ game-kit, content/ids, evidence
platform   ──→ game-kit, progress
content    ──→ zod
game-kit   ──→ content/ids(仅类型与常量)
i18n       ──→ content/ids

禁止的方向写得同样明确,因为它们每一条都对应一次真实事故:

  • content 依赖 toolkit:会让内容体检把题型逻辑拖进构建期;
  • game-kit 依赖 Vue / Phaser / zod:契约层一旦有渲染或校验依赖,纯逻辑单测就跑不起来;
  • toolkit 依赖 Vue / Phaser:审计 CLI 在 Node 里跑,import 到 DOM 就炸;
  • apps/site 直接 import phaser 或 @mmf/content/full:首屏体积与懒加载双双失守。

为什么 toolkit 与 toolkit-ui 要拆成两个包?因为六件工具的判定必须能在 Node 里被 audit:levels 审计,而它们的界面是 Vue。混在一起会把 Vue/DOM 拖进纯逻辑测试与审计 CLI。同理,templates 的 *.logic.ts 可以 import toolkit,但不该 import 画布场景。

2.4 TypeScript:strict,以及「刻意没有 paths」

tsconfig.json 只有一份,全仓共用(没有各包独立的 tsconfig)。核心配置:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "resolveJsonModule": true,
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "incremental": true,
    "jsx": "preserve",
    "types": ["vite/client", "node"]
  },
  "include": [
    "packages/*/src/**/*.ts",
    "packages/*/src/**/*.vue",
    "games/*/src/**/*.ts",
    "apps/site/src/**/*.ts",
    "apps/site/src/**/*.vue",
    "e2e/**/*.ts",
    "scripts/**/*.ts",
    "*.config.ts",
    "apps/*/*.config.ts"
  ],
  "exclude": ["**/node_modules", "**/dist", "**/vendor"]
}

几个值得逐条解释的选择:

  • strict + noUnusedLocals + noUnusedParameters。 后者看着烦,但它是「删代码」的护栏:重构后残留的变量会立刻红,而不是变成一颗哑弹。
  • moduleResolution: "bundler"。 这是让 import { loadLevel } from '@mmf/content' 能解析到 ./src/index.ts 的关键——它按 package.json 的 exports 找入口,并允许无扩展名导入。若改成 node16,所有直连 .ts 的 exports 都会失配。
  • verbatimModuleSyntax + isolatedModules。 强制把纯类型导入写成 import type。这不是洁癖:import type 会被类型擦除,而普通 import 会留下运行时依赖。项目里多处靠这一点保住 chunk 边界(例如只取常量与类型的地方)。
  • allowImportingTsExtensions。 只为一处例外开口:apps/site/vite.config.ts 要写 import { CSP_TAG } from './src/shell/csp.ts'。原因是 Vite 8 的 native config loader 不支持无扩展名的相对导入,启动时会警告。配置注释里写得很清楚:只放行不强制,其余相对导入保持原样。
  • noEmit + incremental。 类型检查只产出 .tsbuildinfo(放在 node_modules/.cache),产物交给 Vite;pnpm typecheck 就是 vue-tsc --noEmit -p tsconfig.json。

没有 paths 别名,是刻意为之。 这里不用 "@mmf/*": ["packages/*/src"] 这种映射,而是让 pnpm 在 node_modules 里建符号链接,TS 通过每个包 package.json 的 exports 找到 ./src/*.ts。好处是「一份真相」:运行时(Vite / Node)、类型检查(TS)与测试(Vitest)走的是同一套解析规则,不存在「类型能过、跑起来找不到」的双份配置。代价是子路径必须写进 exports——但这正好也是代码分割的清单,漏写会立刻暴露。

2.5 ESLint flat config:把架构约束写成规则

eslint.config.js 是 flat config(数组,无 .eslintrc 层级)。它先铺三个通用配置:

export default tseslint.config(
  { ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**',
              '**/playwright-report/**', '**/test-results/**', '**/vendor/**', '.scratch/**'] },
  js.configs.recommended,
  ...tseslint.configs.recommended,
  ...pluginVue.configs['flat/recommended'],
  { files: ['**/*.vue'], languageOptions: { parserOptions: { parser: tseslint.parser } } },
  // …
);

然后是三段架构约束——它们不是风格规则,而是第 01 章那三条铁律的可执行版本:

// ① 纯逻辑层:不许随机、不许时钟、不许 Phaser/Vue
{
  files: ['packages/toolkit/src/**/*.ts', 'packages/templates/src/**/*.logic.ts'],
  rules: {
    'no-restricted-properties': ['error',
      { object: 'Math', property: 'random', message: '纯逻辑层禁止 Math.random,请注入种子。' },
      { object: 'Date', property: 'now', message: '纯逻辑层禁止 Date.now,请注入 now。' },
    ],
    'no-restricted-imports': ['error', { paths: [
      { name: 'phaser', message: '纯逻辑层禁止 Phaser。' },
      { name: 'vue', message: '纯逻辑层禁止 Vue。' },
    ]}],
  },
},

// ② 平台出口唯一:游戏/模板/外壳不许直接碰浏览器存储与网络
{
  files: ['packages/{toolkit,templates,games}/**/*.ts', 'apps/site/src/**/*.{ts,vue}'],
  rules: {
    'no-restricted-globals': ['error', 'fetch', 'XMLHttpRequest', 'WebSocket', 'localStorage', 'indexedDB'],
  },
},

// ③ 外壳不许把重东西拖进首屏
{
  files: ['apps/site/src/**/*.{ts,vue}'],
  rules: {
    'no-restricted-imports': ['error', { paths: [
      { name: '@mmf/content/full', message: '外壳不许直接引入完整内容:用 loadLevel()。' },
      { name: 'phaser', message: '外壳不许直接 import Phaser:它只属于关卡 chunk。' },
    ]}],
  },
},

三处细节:

  1. localStorage 只对游戏/工具/外壳禁用,packages/platform 内部是允许的。 这就是「平台出口唯一」的工程含义——不是全仓禁用,而是只留一个豁免区。
  2. evidence 与 progress 没有被列进第 ① 段的 files 里,但它们同样是纯逻辑包。规则的覆盖边界是「当前最容易被写坏的地方」,不是全部;新加纯逻辑包时要记得扩这条 files。
  3. .vue 也吃这套规则。 因为 pluginVue.configs['flat/recommended'] 的 languageOptions.parserOptions.parser 指到了 tseslint.parser,所以 apps/site/src/**/*.vue 里的 <script setup> 同样受 no-restricted-globals 管辖。

此外还有一组为 scripts/ 与配置文件准备的白名单全局变量(console、process、Buffer、setTimeout 等),因为它们是 Node 脚本而不是浏览器代码。风格类规则则做了减法:vue/multi-word-component-names、vue/max-attributes-per-line 等关掉,@typescript-eslint/no-unused-vars 放行 _ 前缀参数。

2.6 Vite:相对 base、chunk 命名与构建期 CSP

apps/site/vite.config.ts 只有 56 行,但每一段都在解决一个具体问题。

import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import type { Plugin } from 'vite';
// 显式带 .ts:Vite 8 的 native config loader 不支持无扩展名相对导入(dev 启动时会警告)。
import { CSP_TAG } from './src/shell/csp.ts';

const phaserTrim = fileURLToPath(
  new URL('../../packages/canvas-kit/vendor/phaser-trim.esm.js', import.meta.url),
);

export default defineConfig({
  base: './',
  plugins: [vue(), tailwindcss(), cspPlugin()],
  resolve: { alias: [{ find: /^phaser$/, replacement: phaserTrim }] },
  server: { host: '127.0.0.1', port: 5192, strictPort: true },
  preview: { host: '127.0.0.1', port: 5193, strictPort: true },
  build: {
    target: 'es2022',
    chunkSizeWarningLimit: 1100,
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [
            {
              name: 'vendor-phaser',
              test: /node_modules[\\/]phaser[\\/]|canvas-kit[\\/]vendor[\\/]phaser-trim/,
            },
          ],
        },
        chunkFileNames(chunk) {
          const id = chunk.facadeModuleId ?? '';
          const level = /[\\/]games[\\/]forge[\\/]src[\\/]levels[\\/]([^\\/]+)\.ts$/.exec(id);
          if (level) return 'assets/level-' + level[1] + '-[hash].js';
          const detail = /[\\/]content[\\/]src[\\/]details[\\/]([^\\/]+)\.level\.json$/.exec(id);
          if (detail) return 'assets/content-' + detail[1] + '-[hash].js';
          if (chunk.name.includes('phaser')) return 'assets/vendor-phaser-[hash].js';
          return 'assets/[name]-[hash].js';
        },
      },
    },
  },
});

base: './'。 产物里的资源引用全是相对路径,于是 apps/site/dist 可以放在任意子目录(GitHub Pages 的项目页、U 盘、Tauri 的 frontendDist、Capacitor 的 webDir),不需要在四处分别改 base。代价是不能用绝对路径写死资源。

Phaser alias。 源码里大家都写 import ... from 'phaser',构建时被换成仓库里的裁剪产物;TS 类型仍从官方 phaser 包解析。这样「引擎只在关卡 chunk 里」这件事只需要一处配置就能维持。注意裁剪产物是一次性生成后入库的,仓库里没有 pnpm phaser:trim 这类脚本,升级 Phaser 时要按 packages/canvas-kit/vendor/README.md 手工重建。

chunk 命名。 三条例正则把「哪个模块」直接写进文件名:assets/level-<id>-[hash].js、assets/content-<id>-[hash].js、assets/vendor-phaser-[hash].js。这不是为了好看——体积门禁与 e2e 都要靠文件名定位「这一关的 chunk 有多大」「首屏引用了哪些文件」,哈希化的通用名会把这些检查变成猜谜。

vendor 分组。 codeSplitting.groups 把 Phaser 与裁剪产物单独归到 vendor-phaser,保证它只进一个 chunk、只被需要画布的关卡引用,而不是被每个关卡各打一份。chunkSizeWarningLimit: 1100 则是对着 check-size.mjs 的「单个懒加载 chunk 原始 ≤ 1100 KB」预算设的,让构建警告与门禁阈值同源。

CSP 只在构建期注入。 插件本体只有六行:

/**
 * 只在构建产物注入 CSP(策略在 src/shell/csp.ts,有单测兜住)。
 * 用标签描述符而不是字符串替换:见 csp.ts 里 `CSP_TAG` 的注释。
 */
function cspPlugin(): Plugin {
  return {
    name: 'mmf-csp',
    apply: 'build',
    transformIndexHtml: () => [CSP_TAG],
  };
}

apply: 'build' 是关键:dev 的 HMR 要连 ws://127.0.0.1:5192,而生产策略里 connect-src 'none' 会把热更新直接掐断。策略本身写在 apps/site/src/shell/csp.ts,并且刻意用 Vite 的标签描述符({ tag, attrs, injectTo })而不是字符串替换 </head>:

export const CSP_DIRECTIVES = [
  `default-src 'self' file:`,
  `script-src 'self' file:`,
  `style-src 'self' 'unsafe-inline' file:`,
  `img-src 'self' data: blob: file:`,
  `font-src 'self' file:`,
  `connect-src 'none'`,
  `media-src 'self' file:`,
  `object-src 'none'`,
  `base-uri 'self'`,
  `form-action 'none'`,
] as const;

这里记录一次真实事故:早期用正则替换 </head>,而 index.html 的注释里恰好出现这个字面量,结果注入内容连同伴随的 <script type="module"> 一起被塞进了注释——dev 的 e2e 全绿,生产产物却是白屏。现在 scripts/check-size.mjs 会先剥掉注释再检查「有没有可执行 module script」「有没有 CSP meta」,把这类假绿在体积门禁那一步就暴露出来。

2.7 端口约定与命令面

两个端口是固定的,都绑定回环地址并开启 strictPort:

命令 端口 用途 为什么必须固定
pnpm dev http://127.0.0.1:5192 Vite 开发服务器 playwright.config.ts 的 baseURL 与 webServer.url 写死它
pnpm preview http://127.0.0.1:5193 预览生产产物 playwright.preview.config.ts 用它对产物跑 e2e

strictPort: true 的含义是「端口被占就报错,不要自动换一个」。这是有意的:E2E 的 webServer 配置会探测这个 URL,如果 Vite 悄悄跑到 5193,Playwright 可能连上了上一次残留的服务,测出一份过期的绿。

根 package.json 的命令面把所有动作收在一处,前缀全部是 workspace 过滤:

"scripts": {
  "dev": "pnpm --filter @mmf/site dev",
  "build": "pnpm --filter @mmf/site build",
  "preview": "pnpm --filter @mmf/site preview",
  "typecheck": "vue-tsc --noEmit -p tsconfig.json",
  "lint": "eslint . --concurrency auto --cache --cache-location node_modules/.cache/eslint",
  "test": "vitest run",
  "content:check": "tsx scripts/content-check.ts",
  "audit:levels": "tsx scripts/audit-levels.ts",
  "pack:check": "node scripts/check-packaging.mjs",
  "size": "node scripts/check-size.mjs",
  "verify": "node scripts/verify.mjs",
  "e2e": "playwright test",
  "e2e:dist": "pnpm build && playwright test --config playwright.preview.config.ts"
}

注意 dev / build / preview 都不是在根目录跑 Vite,而是 pnpm --filter @mmf/site——根目录没有 index.html,产品入口只有 apps/site 一个。e2e:dist 则先 build 再跑 preview 配置,因为「生产产物」与「dev 服务器」的差异(CSP、chunk 切分、相对 base)只有这条命令能覆盖。

这套命令面在第 03 章会变成一条可执行的体验路径,第 18 章会拆成质量门禁的八个步骤。

2.8 本章要点

  • 版本全部写成精确值,packageManager 锁 pnpm 11.22.0;升级任何版本都单独一个 PR 并跑全量门禁。
  • workspace 三行:apps/*(应用)、packages/*(库)、games/*(玩法包);allowBuilds 白名单只放行三个需要原生构建的依赖。
  • 包没有构建步骤,exports 直连 .ts;子路径既是解析入口也是代码分割点,漏写一条就进不了运行时。
  • 依赖方向是硬约束:game-kit 不依赖 Vue/Phaser/zod,toolkit 不依赖 Vue/Phaser,外壳不直接 import Phaser 与 content/full。
  • tsconfig 全仓一份、strict、moduleResolution: bundler、verbatimModuleSyntax;刻意没有 paths,跨包解析靠 pnpm 符号链接 + exports。
  • eslint.config.js 三段架构规则分别守住「纯逻辑无随机/无时钟/无框架」「平台出口唯一」「外壳不拖重物」。
  • Vite:base: './'、Phaser alias 指向裁剪产物、chunk 名带关卡 id、vendor 分组独立、CSP 只在 apply: 'build' 时注入。
  • 端口固定 5192(dev)/ 5193(preview)并开 strictPort,因为 e2e 的 baseURL 与 webServer 就写的是它们。