Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/api/src/bootstrap/release-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* 读取内核产物的目标版本(来自产物根目录的 `RELEASE.json`)。
*
* 为什么单独成文件而不是留在 `server.ts` 里:`server.ts` 是带顶层副作用的
* 进程入口(`await main()`),任何 import 都会真的去启动进程,因此无法直接做
* 单元测试。这段路径推导又恰好是最容易写错的地方——产物布局是
* `releases/<version>/apps/api/dist/server.js`,而 `RELEASE.json` 在
* `releases/<version>/`,即 `dist` 的**上三层**。少一层就会得到 `unknown`,
* 表现为自举时 runner 报「任务未记录目标版本,无法定位迁移产物」。
*/

import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';

/**
* 从入口模块 URL 向上查找 `RELEASE.json`。
*
* 依次尝试 dist 的 1/2/3 层父目录,覆盖:
* - 产物布局 `<version>/apps/api/dist/server.js` → 3 层;
* - 源码构建 `apps/api/dist/server.js` → 2 层(仓库根有时也有 RELEASE.json);
* - 单层打包布局 `dist/server.js` → 1 层。
*
* 找不到或内容不可解析时返回 `'unknown'`;调用方必须显式拒绝该值,
* 不能把它当成合法版本继续执行。
*/
export function resolveTargetVersion(moduleUrl: string | URL): string {
const here = path.dirname(moduleUrl instanceof URL ? moduleUrl.pathname : fileUrlPath(moduleUrl));
const candidates = [
path.resolve(here, '..', 'RELEASE.json'),
path.resolve(here, '..', '..', 'RELEASE.json'),
path.resolve(here, '..', '..', '..', 'RELEASE.json'),
];
for (const candidate of candidates) {
if (!existsSync(candidate)) continue;
try {
const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as { version?: unknown };
if (typeof parsed.version === 'string' && parsed.version.length > 0) return parsed.version;
} catch {
// 读取或解析失败时继续尝试下一个候选。
}
}
return 'unknown';
}

function fileUrlPath(moduleUrl: string): string {
try {
return new URL(moduleUrl).pathname;
} catch {
return moduleUrl;
}
}
50 changes: 49 additions & 1 deletion apps/api/src/lib/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { cp, mkdir, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { PluginDefinition, PluginDependency, PluginExtensionConsumer } from '@stackpanel/sdk';
Expand Down Expand Up @@ -178,6 +178,54 @@ function pluginsDir(): string {
return path.join(resolveStackPanelDataDir(), 'plugins');
}

/**
* 让「数据目录里的插件」能解析到内核自带的依赖。
*
* 插件包被装在 `<data>/plugins/<id>/`,而它 import 的 `@stackpanel/sdk`、`zod`
* 等裸包位于版本目录的 `node_modules`(`<root>/current/node_modules`)。
* Node 的 ESM 解析只会从插件目录**向上**找 `node_modules`,数据目录通常在
* 版本目录之外(`/var/lib/stackpanel` vs `/opt/stackpanel`),这条路径永远
* 到不了内核依赖——结果是每个内置插件都被判为 `Skipping invalid plugin`,
* 市场与主题首页全空,而进程本身看起来完全正常。
*
* 做法是在数据目录放一个**符号链接** `node_modules -> <root>/current/node_modules`:
* - 不复制依赖(否则会出现第二份 zod 实例、`instanceof` 失效);
* - 升级切换 `current` 后链接自动指向新版本,不需要重写;
* - 只在目标不存在时创建,开发布局下数据目录本来就有真实 node_modules 时不介入。
*
* 失败只记日志:插件加载会因为找不到依赖而各自报错,这里再抛异常只会让
* 整个进程起不来,反而更难定位。
*/
export async function ensurePluginModuleResolution(
log: (message: string) => void = () => {},
): Promise<void> {
const dataDir = resolveStackPanelDataDir();
const link = path.join(dataDir, 'node_modules');
if (existsSync(link)) return;
// 内核进程的工作目录是同版本目录下的 `apps/api`(见 ecosystem.config.cjs),
// 因此 `<cwd>/../../node_modules` 就是该版本目录的 node_modules;再兜底
// `<data>/../current/node_modules`(数据目录与安装根相邻的默认布局)。
const cwd = process.cwd();
const candidates = [
path.resolve(cwd, '..', '..', 'node_modules'),
path.resolve(cwd, 'node_modules'),
path.join(path.dirname(dataDir), 'current', 'node_modules'),
];
const target = candidates.find((candidate) => existsSync(candidate));
if (!target) return;
try {
await mkdir(dataDir, { recursive: true });
await symlink(target, link, 'dir');
log(`[plugins] 已建立模块解析桥接:${link} -> ${target}`);
} catch (error) {
log(
`[plugins] 无法建立模块解析桥接(${link} -> ${target}):${
error instanceof Error ? error.message : String(error)
}`,
);
}
}

export function pluginPackageDir(id: string): string {
return path.join(pluginsDir(), id);
}
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/plugin-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import type { NotificationChannel, NotificationTypeDefinition } from '@stackpane
import type { PrismaClient } from '@stackpanel/db';
import { peekRedis } from '@stackpanel/db';
import { EXTENSION_POINTS } from '@stackpanel/sdk';
import { loadPluginDefinition, scanPlugins, seedBuiltinPlugins } from './lib/plugins.ts';
import {
ensurePluginModuleResolution,
loadPluginDefinition,
scanPlugins,
seedBuiltinPlugins,
} from './lib/plugins.ts';
import { packageManager } from './packages/manager.ts';
import { MarketService } from './market/service.ts';
import { getCatalogRefresh } from './distribution/runtime.ts';
Expand Down Expand Up @@ -257,6 +262,7 @@ export function createPluginHost(options: PluginHostOptions): PluginHost {
media,
coherence,
async registerPlugins(): Promise<void> {
await ensurePluginModuleResolution((message) => console.info(message));
await seedBuiltinPlugins();
// 回收上次崩溃遗留的未引用暂存/备份产物(§7.6/§7.8);活动目录不受影响。
await packageManager.gcAll().catch(() => undefined);
Expand Down
20 changes: 3 additions & 17 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
* 数据库故障绝不能让生产实例回落到可写配置的页面。
*/

import { existsSync, readFileSync } from 'node:fs';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { BootstrapStateStore, defaultBootstrapStateDir, initialState } from './bootstrap/state.ts';
import { resolveTargetVersion as readReleaseVersion } from './bootstrap/release-version.ts';

/**
* 解析状态根目录与自举状态目录。
Expand Down Expand Up @@ -46,22 +47,7 @@ function resolveStateRoots(): { root: string; bootstrapDir: string } {

/** 读取目标内核版本;来自产物内的 `RELEASE.json`。 */
function resolveTargetVersion(moduleUrl: string): string {
const here = path.dirname(new URL(moduleUrl).pathname);
// dist/server.js → 产物根是 dist 的上一级
const candidates = [
path.resolve(here, '..', 'RELEASE.json'),
path.resolve(here, '..', '..', 'RELEASE.json'),
];
for (const candidate of candidates) {
if (!existsSync(candidate)) continue;
try {
const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as { version?: string };
if (typeof parsed.version === 'string') return parsed.version;
} catch {
// 读取失败时继续尝试下一个候选。
}
}
return 'unknown';
return readReleaseVersion(moduleUrl);
}

/**
Expand Down
68 changes: 68 additions & 0 deletions apps/api/tests/unit/bootstrap-release-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';

import { resolveTargetVersion } from '../../src/bootstrap/release-version.ts';

/**
* 产物布局回归:`RELEASE.json` 在 `releases/<version>/`,入口在
* `releases/<version>/apps/api/dist/server.js`——距 RELEASE.json 三层。
*
* 少认一层会让 targetVersion 恒为 `unknown`,自举时 runner 直接失败并报
* 「任务未记录目标版本,无法定位迁移产物」,而安装器看起来完全成功。
*/
describe('resolveTargetVersion', () => {
const created: string[] = [];

afterEach(() => {
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true });
});

function makeRelease(version: string, entryRel: string): string {
const root = mkdtempSync(path.join(tmpdir(), 'sp-release-'));
created.push(root);
writeFileSync(path.join(root, 'RELEASE.json'), JSON.stringify({ version }), 'utf8');
const entryDir = path.join(root, path.dirname(entryRel));
mkdirSync(entryDir, { recursive: true });
const entry = path.join(root, entryRel);
writeFileSync(entry, '// entry\n', 'utf8');
return entry;
}

it('产物布局(apps/api/dist/server.js)读到版本', () => {
const entry = makeRelease('0.2.0', 'apps/api/dist/server.js');
expect(resolveTargetVersion(entry)).toBe('0.2.0');
});

it('接受 file:// URL 形式的入口', () => {
const entry = makeRelease('1.4.2', 'apps/api/dist/server.js');
expect(resolveTargetVersion(new URL(`file://${entry}`))).toBe('1.4.2');
});

it('单层布局(dist/server.js)也读到版本', () => {
const entry = makeRelease('9.9.9', 'dist/server.js');
expect(resolveTargetVersion(entry)).toBe('9.9.9');
});

it('找不到 RELEASE.json 时返回 unknown,而不是猜一个版本', () => {
const root = mkdtempSync(path.join(tmpdir(), 'sp-release-none-'));
created.push(root);
const entryDir = path.join(root, 'apps/api/dist');
mkdirSync(entryDir, { recursive: true });
const entry = path.join(entryDir, 'server.js');
writeFileSync(entry, '// entry\n', 'utf8');
expect(resolveTargetVersion(entry)).toBe('unknown');
});

it('RELEASE.json 缺少 version 时返回 unknown', () => {
const root = mkdtempSync(path.join(tmpdir(), 'sp-release-bad-'));
created.push(root);
writeFileSync(path.join(root, 'RELEASE.json'), JSON.stringify({ role: 'kernel' }), 'utf8');
const entryDir = path.join(root, 'apps/api/dist');
mkdirSync(entryDir, { recursive: true });
const entry = path.join(entryDir, 'server.js');
writeFileSync(entry, '// entry\n', 'utf8');
expect(resolveTargetVersion(entry)).toBe('unknown');
});
});
99 changes: 99 additions & 0 deletions apps/api/tests/unit/plugin-module-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readlinkSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';

/**
* 插件装在数据目录(`/var/lib/stackpanel/data/plugins/<id>`),但它们的裸导入
* (`@stackpanel/sdk`、`zod`)只存在于版本目录的 `node_modules`
* (`/opt/stackpanel/releases/<v>/node_modules`)。Node 的解析只会从插件目录
* **向上**找 `node_modules`,因此数据目录与安装根不在同一棵树时,每个内置插件
* 都会变成 `Skipping invalid plugin: Cannot find package 'zod'`——进程正常、
* 首页 404、市场为空,安装与自举却都报成功。
*
* `ensurePluginModuleResolution` 在数据目录放一个指向版本目录的符号链接,
* 升级切换 `current` 后自动跟随新版本。这里钉死它的判定顺序与幂等性。
*/
import { ensurePluginModuleResolution } from '../../src/lib/plugins.ts';

describe('ensurePluginModuleResolution', () => {
const created: string[] = [];
const savedDataDir = process.env['STACKPANEL_DATA_DIR'];

afterEach(() => {
if (savedDataDir === undefined) delete process.env['STACKPANEL_DATA_DIR'];
else process.env['STACKPANEL_DATA_DIR'] = savedDataDir;
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true });
});

function tree(): { root: string; releaseDir: string; dataDir: string; cwd: string } {
const root = mkdtempSync(path.join(tmpdir(), 'sp-modres-'));
created.push(root);
// 产物布局:releases/<version>/{apps/api,node_modules}
const releaseDir = path.join(root, 'releases', '0.2.0');
const cwd = path.join(releaseDir, 'apps', 'api');
const dataDir = path.join(root, 'state', 'data');
mkdirSync(path.join(releaseDir, 'node_modules'), { recursive: true });
mkdirSync(cwd, { recursive: true });
mkdirSync(dataDir, { recursive: true });
writeFileSync(path.join(releaseDir, 'node_modules', '.keep'), '');
return { root, releaseDir, dataDir, cwd };
}

it('在数据目录建立指向当前版本 node_modules 的符号链接', async () => {
const { releaseDir, dataDir, cwd } = tree();
process.env['STACKPANEL_DATA_DIR'] = dataDir;
const previous = process.cwd();
process.chdir(cwd);
try {
await ensurePluginModuleResolution();
} finally {
process.chdir(previous);
}
const link = path.join(dataDir, 'node_modules');
// macOS 的 tmpdir 是 /var → /private/var 符号链接,比较前统一取真实路径。
expect(realpathSync(readlinkSync(link))).toBe(
realpathSync(path.join(releaseDir, 'node_modules')),
);
});

it('已存在 node_modules 时不覆盖(开发布局与重复启动都走这条)', async () => {
const { dataDir } = tree();
process.env['STACKPANEL_DATA_DIR'] = dataDir;
const existing = path.join(dataDir, 'node_modules');
mkdirSync(existing, { recursive: true });
writeFileSync(path.join(existing, 'sentinel'), '');
const logs: string[] = [];
await ensurePluginModuleResolution((message) => logs.push(message));
// 保持既有内容:开发布局下数据目录自带真实 node_modules,不该被符号链接替换。
expect(existsSync(path.join(existing, 'sentinel'))).toBe(true);
expect(lstatSync(existing).isSymbolicLink()).toBe(false);
expect(logs).toHaveLength(0);
});

it('找不到版本目录时不创建悬空链接,只记一条日志', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'sp-modres-none-'));
created.push(root);
const dataDir = path.join(root, 'data');
mkdirSync(dataDir, { recursive: true });
process.env['STACKPANEL_DATA_DIR'] = dataDir;
const logs: string[] = [];
const previous = process.cwd();
process.chdir(root);
try {
await ensurePluginModuleResolution((message) => logs.push(message));
} finally {
process.chdir(previous);
}
expect(() => readlinkSync(path.join(dataDir, 'node_modules'))).toThrow();
});
});
Loading
Loading