完成 Editor Agent Mock Agent P1 收尾

接入 Web Project 契约、SpacetimeDB 表与 api-server 控制面
新增 Mock Agent、静态构建 runner 与独立预览网关
补齐 /editor/agent 前端页面、服务客户端和 SSE 订阅
修复 sandbox 预览资源跨域加载并补充并发保护
接入本地 dev 预览端口漂移与服务身份初始化
更新 P1 技术方案、验收清单和 Hermes 共享记忆
This commit is contained in:
2026-06-16 17:31:25 +08:00
parent 80a382b034
commit 4b09ce3096
404 changed files with 14886 additions and 2497 deletions

View File

@@ -55,8 +55,8 @@ export function parsePortRangeSpec(value) {
throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`);
}
if (end - start + 1 < 4) {
throw new Error(`端口段至少需要 4 个端口: ${spec}`);
if (end - start + 1 < 5) {
throw new Error(`端口段至少需要 5 个端口: ${spec}`);
}
return {start, end, label: `${start}-${end}`};
@@ -118,6 +118,7 @@ export function mapDevPortsToPortRange(portRange) {
apiPort: normalizedRange.start + 1,
spacetimePort: normalizedRange.start + 2,
adminWebPort: normalizedRange.start + 3,
webProjectPreviewPort: normalizedRange.start + 4,
range: normalizedRange,
};
}
@@ -556,6 +557,7 @@ export async function resolveDevStackPorts(config) {
['api', config.api],
['web', config.web],
['adminWeb', config.adminWeb],
['webProjectPreview', config.webProjectPreview],
].filter(([, portConfig]) => Boolean(portConfig));
const result = {};

View File

@@ -24,7 +24,7 @@ function reservePort(port) {
}
describe('dev stack port utils', () => {
it('解析端口段并映射到四个 dev 端口', () => {
it('解析端口段并映射到 dev 端口和预览网关第五端口', () => {
expect(parsePortRangeSpec('10000-10099')).toEqual({
start: 10000,
end: 10099,
@@ -35,6 +35,7 @@ describe('dev stack port utils', () => {
apiPort: 10001,
spacetimePort: 10002,
adminWebPort: 10003,
webProjectPreviewPort: 10004,
});
});
@@ -74,9 +75,11 @@ describe('dev stack port utils', () => {
api: {host: '127.0.0.1', preferredPort: 0},
web: {host: '127.0.0.1', preferredPort: 0},
adminWeb: {host: '127.0.0.1', preferredPort: 0},
webProjectPreview: {host: '127.0.0.1', preferredPort: 0},
});
expect(new Set(Object.values(resolvedPorts)).size).toBe(4);
expect(new Set(Object.values(resolvedPorts)).size).toBe(5);
expect(resolvedPorts.webProjectPreview).toBeGreaterThan(0);
});
it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => {

View File

@@ -37,9 +37,18 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env';
const webProjectRunnerBinPath = resolve(
serverRsDir,
'target/debug',
process.platform === 'win32' ? 'web-project-runner.exe' : 'web-project-runner',
);
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? '' : '/usr/bin/env';
const WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV =
'GENARRATIVE_SPACETIME_WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET';
const WEB_PROJECT_SERVICE_IDENTITY_CACHE_FILE = 'api-server-web-project-identity.json';
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
const SNAPSHOT_SERVICE_NAMES = [...SERVICE_NAMES, 'web-project-preview'];
const SERVICE_ALIASES = new Map([
['api', 'api-server'],
['admin', 'admin-web'],
@@ -62,6 +71,8 @@ function usage() {
--web-port <port> 主站 Vite 端口
--admin-web-host <host> 后台 Vite 监听地址
--admin-web-port <port> 后台 Vite 端口
--web-project-preview-host <host> Web Project preview gateway 监听地址
--web-project-preview-port <port> Web Project preview gateway 端口
--spacetime-host <host> SpacetimeDB 监听地址
--spacetime-port <port> SpacetimeDB 端口
--spacetime-data-dir <path> SpacetimeDB 本地数据目录
@@ -113,6 +124,12 @@ function parseArgs(argv, baseEnv) {
webPort: normalizePort(env.WEB_PORT, 3000),
adminWebHost: env.ADMIN_WEB_HOST || '127.0.0.1',
adminWebPort: normalizePort(env.ADMIN_WEB_PORT, 3102),
webProjectPreviewHost:
env.GENARRATIVE_WEB_PROJECT_PREVIEW_HOST || '127.0.0.1',
webProjectPreviewPort: normalizePort(
env.GENARRATIVE_WEB_PROJECT_PREVIEW_PORT,
3104,
),
spacetimeHost: env.SPACETIME_HOST || '127.0.0.1',
spacetimePort: normalizePort(env.SPACETIME_PORT, 3101),
spacetimeDataDir: resolve(serverRsDir, '.spacetimedb/local/data'),
@@ -130,6 +147,14 @@ function parseArgs(argv, baseEnv) {
preserveDatabase: false,
migrationBootstrapSecret: '',
migrationBootstrapSecretMode: 'auto',
webProjectServiceBootstrapSecret: String(
env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV] ?? '',
).trim(),
webProjectServiceBootstrapSecretMode: String(
env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV] ?? '',
).trim()
? 'manual'
: 'auto',
watch: false,
interactive: true,
};
@@ -175,6 +200,17 @@ function parseArgs(argv, baseEnv) {
options.adminWebPort = normalizePort(readValue(), options.adminWebPort);
explicitOptions.add('adminWebPort');
break;
case '--web-project-preview-host':
options.webProjectPreviewHost = readValue();
explicitOptions.add('webProjectPreviewHost');
break;
case '--web-project-preview-port':
options.webProjectPreviewPort = normalizePort(
readValue(),
options.webProjectPreviewPort,
);
explicitOptions.add('webProjectPreviewPort');
break;
case '--spacetime-host':
options.spacetimeHost = readValue();
options.spacetimeServerUrl = '';
@@ -226,6 +262,14 @@ function parseArgs(argv, baseEnv) {
options.migrationBootstrapSecret = '';
options.migrationBootstrapSecretMode = 'disabled';
break;
case '--web-project-service-bootstrap-secret':
options.webProjectServiceBootstrapSecret = readValue();
options.webProjectServiceBootstrapSecretMode = 'manual';
break;
case '--no-web-project-service-bootstrap-secret':
options.webProjectServiceBootstrapSecret = '';
options.webProjectServiceBootstrapSecretMode = 'disabled';
break;
case '--watch':
options.watch = true;
break;
@@ -262,9 +306,27 @@ function resolveDevStackStatePath(root = repoRoot) {
return join(root, '.app/dev-stack.json');
}
function buildWebProjectPreviewPublicBaseUrl({host, port}) {
return `http://${resolveClientHost(host)}:${port}`;
}
function resolveWebProjectPreviewFrameAncestors({baseEnv, webOrigin}) {
const explicitAncestors = String(
baseEnv.GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS ?? '',
).trim();
if (explicitAncestors) {
const ancestors = uniqueNonEmpty([explicitAncestors, webOrigin]).join(' ');
return ancestors || webOrigin;
}
const webUrl = new URL(webOrigin);
const localhostOrigin = `${webUrl.protocol}//localhost:${webUrl.port}`;
return uniqueNonEmpty([webOrigin, localhostOrigin]).join(' ');
}
function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {
const services = {};
for (const serviceName of SERVICE_NAMES) {
for (const serviceName of SNAPSHOT_SERVICE_NAMES) {
services[serviceName] = buildDevStackServiceSnapshot(
runner,
serviceName,
@@ -341,6 +403,12 @@ function resolveDevStackServiceEndpoint(runner, serviceName) {
port: options.adminWebPort,
url: `http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`,
};
case 'web-project-preview':
return {
host: options.webProjectPreviewHost,
port: options.webProjectPreviewPort,
url: state.webProjectPreviewPublicBaseUrl,
};
default:
return {
host: null,
@@ -383,6 +451,8 @@ function resolveDevStackServiceCommand(runner, serviceName) {
`--port=${options.adminWebPort}`,
'--strictPort',
].join(' ');
case 'web-project-preview':
return 'api-server embedded Web Project preview gateway';
default:
return null;
}
@@ -779,6 +849,10 @@ class DevRunner {
adminWebTargetHost: resolveClientHost(options.adminWebHost),
spacetimeServer: initialSpacetimeServer,
apiTarget: `http://${resolveClientHost(options.apiHost)}:${options.apiPort}`,
webProjectPreviewPublicBaseUrl: buildWebProjectPreviewPublicBaseUrl({
host: options.webProjectPreviewHost,
port: options.webProjectPreviewPort,
}),
portRange: null,
portRangeReservation: null,
};
@@ -851,9 +925,16 @@ class DevRunner {
if (!this.explicitOptions.has('adminWebPort')) {
this.options.adminWebPort = mappedPorts.adminWebPort;
}
if (!this.explicitOptions.has('webProjectPreviewPort')) {
this.options.webProjectPreviewPort = mappedPorts.webProjectPreviewPort;
}
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${this.options.spacetimePort}`;
this.state.apiTarget = `http://${this.state.apiTargetHost}:${this.options.apiPort}`;
this.state.webProjectPreviewPublicBaseUrl = buildWebProjectPreviewPublicBaseUrl({
host: this.options.webProjectPreviewHost,
port: this.options.webProjectPreviewPort,
});
}
shouldValidateSpacetimeToolVersion(command) {
@@ -973,6 +1054,11 @@ class DevRunner {
preferredPort: options.apiPort,
portRange: this.state.portRange,
};
portConfig.webProjectPreview = {
host: options.webProjectPreviewHost,
preferredPort: options.webProjectPreviewPort,
portRange: this.state.portRange,
};
}
if (command === 'all' || command === 'web') {
@@ -1021,6 +1107,9 @@ class DevRunner {
if (resolvedPorts.adminWeb) {
options.adminWebPort = resolvedPorts.adminWeb;
}
if (resolvedPorts.webProjectPreview) {
options.webProjectPreviewPort = resolvedPorts.webProjectPreview;
}
this.state.apiTargetHost = resolveClientHost(options.apiHost);
this.state.adminWebTargetHost = resolveClientHost(options.adminWebHost);
@@ -1028,6 +1117,10 @@ class DevRunner {
this.state.spacetimeServer = `http://${options.spacetimeHost}:${options.spacetimePort}`;
}
this.state.apiTarget = `http://${this.state.apiTargetHost}:${options.apiPort}`;
this.state.webProjectPreviewPublicBaseUrl = buildWebProjectPreviewPublicBaseUrl({
host: options.webProjectPreviewHost,
port: options.webProjectPreviewPort,
});
}
registerServices() {
@@ -1092,6 +1185,7 @@ class DevRunner {
console.log(`[dev] web: http://127.0.0.1:${options.webPort}`);
console.log(`[dev] admin web: http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`);
console.log(`[dev] api-server: ${state.apiTarget}`);
console.log(`[dev] web project preview: ${state.webProjectPreviewPublicBaseUrl}`);
console.log(`[dev] spacetime: ${state.spacetimeServer}`);
console.log(`[dev] database: ${options.database}`);
}
@@ -1266,6 +1360,7 @@ class DevRunner {
async publishSpacetimeModule() {
const env = buildLocalRustProcessEnv(this.baseEnv);
this.prepareMigrationBootstrapSecret(env);
this.prepareWebProjectServiceBootstrapSecret(env);
const args = buildSpacetimePublishArgs({
database: this.options.database,
@@ -1304,12 +1399,55 @@ class DevRunner {
console.log(`[dev:spacetime] 迁移引导密钥: ${this.options.migrationBootstrapSecret}`);
}
prepareWebProjectServiceBootstrapSecret(env) {
switch (this.options.webProjectServiceBootstrapSecretMode) {
case 'auto':
if (!this.options.webProjectServiceBootstrapSecret) {
this.options.webProjectServiceBootstrapSecret = randomHex(32);
}
break;
case 'manual':
if (this.options.webProjectServiceBootstrapSecret.length < 16) {
throw new Error('Web Project 服务身份引导密钥至少需要 16 个字符');
}
break;
case 'disabled':
delete env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV];
console.log('[dev:spacetime] 未启用 Web Project 服务身份引导密钥');
return;
default:
throw new Error(
`未知 Web Project 服务身份引导密钥模式: ${this.options.webProjectServiceBootstrapSecretMode}`,
);
}
env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV] =
this.options.webProjectServiceBootstrapSecret;
this.state.webProjectServiceBootstrapSecretPrepared = true;
console.log(
`[dev:spacetime] 已准备 Web Project 服务身份引导密钥: mode=${this.options.webProjectServiceBootstrapSecretMode}, length=${this.options.webProjectServiceBootstrapSecret.length}`,
);
}
async ensureApiServerSpacetimeToken() {
const existingToken = String(this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
if (existingToken && shouldTrustExistingSpacetimeToken(existingToken, this.state.spacetimeServer)) {
return;
}
const cachedIdentity = readCachedSpacetimeIdentity(
this.options.spacetimeDataDir,
this.state.spacetimeServer,
);
if (cachedIdentity) {
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN = cachedIdentity.token;
this.state.spacetimeIdentity = cachedIdentity.identity;
console.log(
`[dev:spacetime] 复用本地 api-server identity: ${cachedIdentity.identity.slice(0, 12)}...`,
);
return;
}
const identityUrl = buildUrl(this.state.spacetimeServer, '/v1/identity');
if (!identityUrl) {
throw new Error(`无法构造 SpacetimeDB identity 地址: ${this.state.spacetimeServer}`);
@@ -1318,6 +1456,11 @@ class DevRunner {
const response = await fetchSpacetimeIdentity(identityUrl);
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN = response.token;
this.state.spacetimeIdentity = response.identity;
cacheSpacetimeIdentity(
this.options.spacetimeDataDir,
this.state.spacetimeServer,
response,
);
console.log(
`[dev:spacetime] 已创建本地 Web identity: ${response.identity.slice(0, 12)}...`,
);
@@ -1325,6 +1468,8 @@ class DevRunner {
async startApiServer(service) {
await this.ensureApiServerSpacetimeToken();
await this.ensureWebProjectServiceIdentityAuthorized();
await this.ensureWebProjectRunnerBinary();
const mergedEnv = buildApiServerProcessEnv({
baseEnv: buildLocalRustProcessEnv(this.baseEnv),
@@ -1382,6 +1527,75 @@ class DevRunner {
service.registerChild(child);
}
async ensureWebProjectRunnerBinary() {
if (String(this.baseEnv.GENARRATIVE_WEB_PROJECT_RUNNER_BIN ?? '').trim()) {
return;
}
if (existsSync(webProjectRunnerBinPath)) {
return;
}
console.log('[dev:api-server] 构建 Web Project runner');
await runForeground(
'cargo',
['build', '-p', 'web-project-runner', '--manifest-path', 'server-rs/Cargo.toml'],
{
cwd: repoRoot,
env: buildLocalRustProcessEnv(this.baseEnv),
label: 'web-project-runner',
},
);
}
async ensureWebProjectServiceIdentityAuthorized() {
if (this.options.webProjectServiceBootstrapSecretMode === 'disabled') {
return;
}
const serviceIdentity = String(this.state.spacetimeIdentity ?? '').trim();
const token = String(this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
if (!serviceIdentity || !token) {
console.warn('[dev:spacetime] 未能确认 api-server SpacetimeDB identity跳过 Web Project 自动授权');
return;
}
if (
this.options.webProjectServiceBootstrapSecretMode !== 'manual' &&
!this.state.webProjectServiceBootstrapSecretPrepared
) {
console.warn(
'[dev:spacetime] 本轮未发布带 Web Project 引导密钥的 spacetime-module跳过自动授权',
);
return;
}
const bootstrapSecret = String(
this.options.webProjectServiceBootstrapSecret ?? '',
).trim();
if (bootstrapSecret.length < 16) {
console.warn('[dev:spacetime] Web Project 服务身份引导密钥缺失,跳过自动授权');
return;
}
try {
const result = await authorizeWebProjectServiceIdentity({
serverUrl: this.state.spacetimeServer,
database: this.options.database,
token,
bootstrapSecret,
serviceIdentity,
});
console.log(
`[dev:spacetime] 已授权 Web Project 本地服务 identity: ${(result.service_identity_hex ?? serviceIdentity).slice(0, 12)}...`,
);
} catch (error) {
console.warn(
`[dev:spacetime] Web Project 本地服务 identity 自动授权失败:${error.message}`,
);
}
}
async waitForApiServer() {
const healthUrl = `${this.state.apiTarget}/healthz`;
const deadline = Date.now() + this.options.apiTimeoutSeconds * 1000;
@@ -1950,6 +2164,19 @@ function buildUrl(baseUrl, path) {
}
}
function buildSpacetimeProcedureUrl(serverUrl, database, procedureName) {
if (!database) {
throw new Error('必须提供 SpacetimeDB 数据库名');
}
try {
const baseUrl = new URL(serverUrl).href.replace(/\/+$/u, '');
return `${baseUrl}/v1/database/${encodeURIComponent(database)}/call/${encodeURIComponent(procedureName)}`;
} catch {
throw new Error(`无法构造 SpacetimeDB procedure 地址: ${serverUrl}`);
}
}
function safeUrlPort(rawUrl) {
try {
return Number(new URL(rawUrl).port);
@@ -2015,6 +2242,171 @@ async function fetchSpacetimeIdentity(url) {
return {identity, token};
}
function readCachedSpacetimeIdentity(spacetimeDataDir, serverUrl) {
const cachePath = resolve(spacetimeDataDir, WEB_PROJECT_SERVICE_IDENTITY_CACHE_FILE);
if (!existsSync(cachePath)) {
return null;
}
try {
const payload = JSON.parse(readFileSync(cachePath, 'utf8'));
if (
payload?.serverUrl !== serverUrl ||
typeof payload.identity !== 'string' ||
typeof payload.token !== 'string' ||
!payload.identity.trim() ||
!payload.token.trim()
) {
return null;
}
return {
identity: payload.identity.trim(),
token: payload.token.trim(),
};
} catch (error) {
console.warn(`[dev:spacetime] 忽略无效本地 api-server identity 缓存: ${error.message}`);
return null;
}
}
function cacheSpacetimeIdentity(spacetimeDataDir, serverUrl, identityPayload) {
const cachePath = resolve(spacetimeDataDir, WEB_PROJECT_SERVICE_IDENTITY_CACHE_FILE);
ensureParentDir(cachePath);
writeFileSync(
cachePath,
`${JSON.stringify(
{
schemaVersion: 1,
serverUrl,
identity: identityPayload.identity,
token: identityPayload.token,
updatedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
'utf8',
);
}
async function authorizeWebProjectServiceIdentity({
serverUrl,
database,
token,
bootstrapSecret,
serviceIdentity,
}) {
const url = buildSpacetimeProcedureUrl(
serverUrl,
database,
'authorize_web_project_service_identity',
);
const input = {
bootstrap_secret: bootstrapSecret,
service_identity_hex: serviceIdentity,
note: 'local api-server web project',
};
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([input]),
});
} catch (error) {
throw new Error(
`SpacetimeDB Web Project 服务身份授权请求失败: ${url}; ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const text = await response.text();
if (!response.ok) {
throw new Error(`SpacetimeDB HTTP ${response.status}: ${trimPreview(text)}`);
}
const result = parseWebProjectServiceIdentityResult(text);
if (!result.ok) {
throw new Error(result.error_message ?? 'Web Project 服务身份授权失败');
}
return result;
}
function parseWebProjectServiceIdentityResult(output) {
const candidates = [];
const trimmed = output.trim();
if (trimmed) {
candidates.push(trimmed);
}
for (const line of output.split(/\r?\n/u)) {
const value = line.trim();
if (value.startsWith('{') || value.startsWith('[')) {
candidates.push(value);
}
}
for (const candidate of candidates) {
try {
return normalizeWebProjectServiceIdentityResult(JSON.parse(candidate));
} catch {
// SpacetimeDB 输出可能夹带说明文本,继续尝试后续候选。
}
}
throw new Error(`无法解析 Web Project 服务身份授权返回值: ${trimPreview(trimmed)}`);
}
function normalizeWebProjectServiceIdentityResult(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
}
if (Array.isArray(value) && value.length >= 3) {
return {
ok: normalizeSatsValue(value[0]),
service_identity_hex: normalizeSatsOption(value[1]),
error_message: normalizeSatsOption(value[2]),
};
}
throw new Error('Web Project 服务身份授权返回值不是合法对象。');
}
function normalizeSatsValue(value) {
if (Array.isArray(value)) {
return value.map((item) => normalizeSatsValue(item));
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, normalizeSatsValue(entry)]),
);
}
return value;
}
function normalizeSatsOption(value) {
if (Array.isArray(value)) {
if (value.length === 2 && value[0] === 0) {
return normalizeSatsValue(value[1]);
}
if (value.length === 0 || value[0] === 1) {
return null;
}
}
return normalizeSatsValue(value);
}
function shouldTrustExistingSpacetimeToken(existingToken, serverUrl) {
const shellToken = String(process.env.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
if (shellToken && shellToken === existingToken) {
@@ -2156,24 +2548,45 @@ function buildApiServerProcessEnv({baseEnv, options, state}) {
GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer,
GENARRATIVE_SPACETIME_DATABASE: options.database,
GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
GENARRATIVE_WEB_PROJECT_RUNNER_BIN:
String(baseEnv.GENARRATIVE_WEB_PROJECT_RUNNER_BIN ?? '').trim() ||
webProjectRunnerBinPath,
GENARRATIVE_WEB_PROJECT_PREVIEW_HOST: options.webProjectPreviewHost,
GENARRATIVE_WEB_PROJECT_PREVIEW_PORT: String(options.webProjectPreviewPort),
GENARRATIVE_WEB_PROJECT_PREVIEW_PUBLIC_BASE_URL:
state.webProjectPreviewPublicBaseUrl ||
buildWebProjectPreviewPublicBaseUrl({
host: options.webProjectPreviewHost,
port: options.webProjectPreviewPort,
}),
GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS:
resolveWebProjectPreviewFrameAncestors({
baseEnv,
webOrigin: `http://${resolveClientHost(options.webHost)}:${options.webPort}`,
}),
};
}
export {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
authorizeWebProjectServiceIdentity,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimeProcedureUrl,
buildSpacetimePublishArgs,
cacheSpacetimeIdentity,
createDevServerSpawnOptions,
createWatchConfigs,
DevRunner,
isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement,
parseWebProjectServiceIdentityResult,
parseArgs,
parseSpacetimeToolVersion,
readCachedSpacetimeIdentity,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
};

View File

@@ -1,24 +1,29 @@
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {join, resolve} from 'node:path';
import {afterEach, describe, expect, test, vi} from 'vitest';
import {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
authorizeWebProjectServiceIdentity,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimeProcedureUrl,
buildSpacetimePublishArgs,
cacheSpacetimeIdentity,
createDevServerSpawnOptions,
createWatchConfigs,
DevRunner,
isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement,
parseWebProjectServiceIdentityResult,
parseArgs,
parseSpacetimeToolVersion,
readCachedSpacetimeIdentity,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
} from './dev.mjs';
@@ -109,7 +114,7 @@ describe('dev scheduler argument routing', () => {
);
});
linuxTest('Linux 启动时按系统级端口段映射四个 dev 端口', async () => {
linuxTest('Linux 启动时按系统级端口段映射 dev 端口和预览网关端口', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
try {
const {command, explicitOptions, options} = parseArgs([], {
@@ -132,8 +137,10 @@ describe('dev scheduler argument routing', () => {
expect(runner.options.apiPort).toBe(22001);
expect(runner.options.spacetimePort).toBe(22002);
expect(runner.options.adminWebPort).toBe(22003);
expect(runner.options.webProjectPreviewPort).toBe(22004);
expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001');
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002');
expect(runner.state.webProjectPreviewPublicBaseUrl).toBe('http://127.0.0.1:22004');
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
@@ -163,6 +170,7 @@ describe('dev scheduler argument routing', () => {
expect(runner.options.apiPort).toBe(8082);
expect(runner.options.spacetimePort).toBe(3101);
expect(runner.options.adminWebPort).toBe(3102);
expect(runner.options.webProjectPreviewPort).toBe(3104);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
@@ -183,6 +191,42 @@ describe('dev scheduler api-server env', () => {
expect(env.GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED).toBe('true');
expect(env.GENARRATIVE_API_PORT).toBe('9091');
expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199');
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_HOST).toBe('127.0.0.1');
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_PORT).toBe('3104');
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_PUBLIC_BASE_URL).toBe('http://127.0.0.1:3104');
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS).toBe(
'http://127.0.0.1:3000 http://localhost:3000',
);
});
test('dev 脚本默认注入 Web Project runner 二进制路径', () => {
const {options} = parseArgs(['api-server'], {});
const env = buildApiServerProcessEnv({
baseEnv: {},
options,
state: {spacetimeServer: 'http://127.0.0.1:3101'},
});
const runnerName =
process.platform === 'win32' ? 'web-project-runner.exe' : 'web-project-runner';
expect(env.GENARRATIVE_WEB_PROJECT_RUNNER_BIN).toBe(
resolve('server-rs', 'target/debug', runnerName),
);
});
test('dev 脚本保留显式 Web Project runner 二进制路径', () => {
const {options} = parseArgs(['api-server'], {});
const env = buildApiServerProcessEnv({
baseEnv: {
GENARRATIVE_WEB_PROJECT_RUNNER_BIN: 'C:\\tools\\web-project-runner.exe',
},
options,
state: {spacetimeServer: 'http://127.0.0.1:3101'},
});
expect(env.GENARRATIVE_WEB_PROJECT_RUNNER_BIN).toBe(
'C:\\tools\\web-project-runner.exe',
);
});
});
@@ -199,6 +243,7 @@ describe('dev scheduler Rust build env', () => {
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
expect(env.RUSTC_WRAPPER).toBe(process.platform === 'win32' ? '' : '/usr/bin/env');
});
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
@@ -233,6 +278,8 @@ describe('dev scheduler stack state file', () => {
webPort: 3010,
adminWebHost: '127.0.0.1',
adminWebPort: 3110,
webProjectPreviewHost: '127.0.0.1',
webProjectPreviewPort: 3112,
spacetimeHost: '127.0.0.1',
spacetimePort: 3120,
spacetimeDataDir: 'server-rs/.spacetimedb/local/data',
@@ -243,6 +290,7 @@ describe('dev scheduler stack state file', () => {
apiTarget: 'http://127.0.0.1:8090',
adminWebTargetHost: '127.0.0.1',
spacetimeServer: 'http://127.0.0.1:3120',
webProjectPreviewPublicBaseUrl: 'http://127.0.0.1:3112',
},
services: new Map([
[
@@ -285,6 +333,14 @@ describe('dev scheduler stack state file', () => {
port: 8090,
url: 'http://127.0.0.1:8090',
});
expect(snapshot.services['web-project-preview']).toMatchObject({
status: 'idle',
pid: null,
host: '127.0.0.1',
port: 3112,
url: 'http://127.0.0.1:3112',
command: 'api-server embedded Web Project preview gateway',
});
});
});
@@ -500,6 +556,85 @@ spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
);
});
test('发布 spacetime-module 时准备 Web Project 服务身份引导密钥', () => {
const {explicitOptions, options} = parseArgs([], {});
const runner = new DevRunner(options, {}, explicitOptions);
const env = {};
runner.prepareWebProjectServiceBootstrapSecret(env);
expect(env.GENARRATIVE_SPACETIME_WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET).toHaveLength(64);
expect(runner.state.webProjectServiceBootstrapSecretPrepared).toBe(true);
});
test('Web Project 服务身份授权使用本地 Web token 调用 procedure', async () => {
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
ok: true,
service_identity_hex: 'c200localidentity',
error_message: null,
}),
})) as unknown as typeof fetch;
await authorizeWebProjectServiceIdentity({
serverUrl: 'http://127.0.0.1:3101',
database: 'genarrative-dev',
token: 'local-web-token',
bootstrapSecret: '0123456789abcdef',
serviceIdentity: 'c200localidentity',
});
expect(globalThis.fetch).toHaveBeenCalledWith(
buildSpacetimeProcedureUrl(
'http://127.0.0.1:3101',
'genarrative-dev',
'authorize_web_project_service_identity',
),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer local-web-token',
}),
body: JSON.stringify([
{
bootstrap_secret: '0123456789abcdef',
service_identity_hex: 'c200localidentity',
note: 'local api-server web project',
},
]),
}),
);
});
test('Web Project 服务身份授权返回值兼容 BSATN 数组形态', () => {
expect(parseWebProjectServiceIdentityResult('[true,[0,"c200localidentity"],[1]]')).toEqual({
ok: true,
service_identity_hex: 'c200localidentity',
error_message: null,
});
});
test('本地 api-server SpacetimeDB identity 写入并复用 data-dir 缓存', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
try {
cacheSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101', {
identity: 'c200cachedidentity',
token: 'cached-token',
});
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101')).toEqual({
identity: 'c200cachedidentity',
token: 'cached-token',
});
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3199')).toBeNull();
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('手动刷新 spacetime 只重新发布模块,不重启 standalone 进程', async () => {
const {explicitOptions, options} = parseArgs([], {});
const runner = new DevRunner(options, {}, explicitOptions);
@@ -533,39 +668,55 @@ spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
});
test('启动 api-server 前为空 token 自动创建本地 Web identity', async () => {
const {explicitOptions, options} = parseArgs([], {
GENARRATIVE_SPACETIME_TOKEN: '',
});
const runner = new DevRunner(options, {}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
identity: 'c200localidentity',
token: 'local-web-token',
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
try {
const {explicitOptions, options} = parseArgs(
['--spacetime-data-dir', tempDir],
{
GENARRATIVE_SPACETIME_TOKEN: '',
},
);
const runner = new DevRunner(options, {}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
identity: 'c200localidentity',
token: 'local-web-token',
}),
})) as unknown as typeof fetch;
await runner.ensureApiServerSpacetimeToken();
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('local-web-token');
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101')).toEqual({
identity: 'c200localidentity',
token: 'local-web-token',
});
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3101/v1/identity',
expect.objectContaining({
method: 'POST',
}),
})) as unknown as typeof fetch;
await runner.ensureApiServerSpacetimeToken();
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('local-web-token');
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3101/v1/identity',
expect.objectContaining({
method: 'POST',
}),
);
);
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('本地 SpacetimeDB 不信任 env 文件中的陈旧 token', async () => {
const originalToken = process.env.GENARRATIVE_SPACETIME_TOKEN;
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
try {
const {explicitOptions, options} = parseArgs([], {
GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token',
});
const {explicitOptions, options} = parseArgs(
['--spacetime-data-dir', tempDir],
{
GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token',
},
);
const runner = new DevRunner(options, {GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token'}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
globalThis.fetch = vi.fn(async () => ({
@@ -582,6 +733,7 @@ spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('fresh-web-token');
} finally {
rmSync(tempDir, {recursive: true, force: true});
if (originalToken === undefined) {
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
} else {