feat(tree): close rust family shell cutover

This commit is contained in:
lix-2026
2026-04-28 16:30:51 +08:00
parent 4ab36a9386
commit 7965c6c107
75 changed files with 9721 additions and 1174 deletions
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
let generatedRoot = "";
const JS_ASSET_NAME = "mnote-tree-shell-runtime.js";
const WASM_ASSET_NAME = "mnote-tree-shell-runtime_bg.wasm";
async function writeFixtureFile(relativePath: string, content: string | Uint8Array) {
const absolutePath = path.join(generatedRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content);
}
describe("/api/tree-shell-runtime/[...asset] route", () => {
beforeEach(async () => {
generatedRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mnote-tree-shell-runtime-"));
process.env.TREE_SHELL_RUNTIME_GENERATED_ROOT = generatedRoot;
});
afterEach(async () => {
delete process.env.TREE_SHELL_RUNTIME_GENERATED_ROOT;
await fs.rm(generatedRoot, { recursive: true, force: true });
generatedRoot = "";
});
it("返回固定资源名 manifest,并暴露同源 js/wasm 资源路径", async () => {
await writeFixtureFile(JS_ASSET_NAME, "export function reduceTreeShellRuntime() {}\n");
await writeFixtureFile(WASM_ASSET_NAME, new Uint8Array([0x00, 0x61, 0x73, 0x6d]));
const { GET } = await import("./route");
const response = await GET(new Request("http://127.0.0.1:3000/api/tree-shell-runtime/manifest.json"), {
params: Promise.resolve({ asset: ["manifest.json"] }),
});
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({
jsGlueAssetPath: `/api/tree-shell-runtime/${JS_ASSET_NAME}`,
wasmAssetPath: `/api/tree-shell-runtime/${WASM_ASSET_NAME}`,
assetPaths: [
`/api/tree-shell-runtime/${JS_ASSET_NAME}`,
`/api/tree-shell-runtime/${WASM_ASSET_NAME}`,
],
generatedRootPath: generatedRoot.split(path.sep).join("/"),
});
});
it("返回具体 wasm/js 资源内容,并拒绝越界路径", async () => {
await writeFixtureFile(JS_ASSET_NAME, "export const runtimeVersion = 1;\n");
await writeFixtureFile(WASM_ASSET_NAME, new Uint8Array([0x00, 0x61, 0x73, 0x6d]));
const { GET } = await import("./route");
const jsResponse = await GET(
new Request(`http://127.0.0.1:3000/api/tree-shell-runtime/${JS_ASSET_NAME}`),
{ params: Promise.resolve({ asset: [JS_ASSET_NAME] }) },
);
expect(jsResponse.status).toBe(200);
expect(jsResponse.headers.get("content-type")).toContain("application/javascript");
expect(await jsResponse.text()).toContain("runtimeVersion");
const wasmResponse = await GET(
new Request(`http://127.0.0.1:3000/api/tree-shell-runtime/${WASM_ASSET_NAME}`),
{ params: Promise.resolve({ asset: [WASM_ASSET_NAME] }) },
);
expect(wasmResponse.status).toBe(200);
expect(wasmResponse.headers.get("content-type")).toBe("application/wasm");
expect(new Uint8Array(await wasmResponse.arrayBuffer())).toEqual(
new Uint8Array([0x00, 0x61, 0x73, 0x6d]),
);
const invalidResponse = await GET(
new Request("http://127.0.0.1:3000/api/tree-shell-runtime/../../secret.txt"),
{ params: Promise.resolve({ asset: ["..", "..", "secret.txt"] }) },
);
expect(invalidResponse.status).toBe(400);
expect(await invalidResponse.json()).toEqual({
error: "非法 tree shell runtime 资源路径",
});
});
});
@@ -0,0 +1,153 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const DEFAULT_GENERATED_ROOT = path.resolve(
process.cwd(),
"..",
"rust",
"crates",
"tree-shell-runtime-wasm",
"generated",
);
const JS_ASSET_NAME = "mnote-tree-shell-runtime.js";
const WASM_ASSET_NAME = "mnote-tree-shell-runtime_bg.wasm";
type TreeShellRuntimeManifest = {
jsGlueAssetPath: string;
wasmAssetPath: string;
assetPaths: string[];
generatedRootPath: string;
};
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
function resolveGeneratedRoot(): string {
return path.resolve(process.env.TREE_SHELL_RUNTIME_GENERATED_ROOT || DEFAULT_GENERATED_ROOT);
}
function toAssetRoutePath(fileName: string): string {
return `/api/tree-shell-runtime/${fileName}`;
}
function sanitizeRelativePath(asset: string[]): string | null {
if (!Array.isArray(asset) || asset.length === 0) {
return null;
}
const decoded = asset.map((segment) => decodeURIComponent(segment));
const joined = decoded.join("/");
if (!joined || joined.includes("\0")) {
return null;
}
const normalized = path.posix.normalize(joined);
if (normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) {
return null;
}
return normalized;
}
async function fileExists(absolutePath: string): Promise<boolean> {
try {
await fs.access(absolutePath);
return true;
} catch {
return false;
}
}
async function buildManifest(): Promise<TreeShellRuntimeManifest> {
const generatedRoot = resolveGeneratedRoot();
const assetCandidates = [JS_ASSET_NAME, WASM_ASSET_NAME];
const existingAssets: string[] = [];
for (const assetName of assetCandidates) {
const absolutePath = path.join(generatedRoot, assetName);
if (await fileExists(absolutePath)) {
existingAssets.push(toAssetRoutePath(assetName));
}
}
return {
jsGlueAssetPath: toAssetRoutePath(JS_ASSET_NAME),
wasmAssetPath: toAssetRoutePath(WASM_ASSET_NAME),
assetPaths: existingAssets,
generatedRootPath: toPosixPath(generatedRoot),
};
}
function guessContentType(absolutePath: string): string {
const extension = path.extname(absolutePath).toLowerCase();
if (extension === ".js" || extension === ".mjs") {
return "application/javascript; charset=utf-8";
}
if (extension === ".wasm") {
return "application/wasm";
}
if (extension === ".json") {
return "application/json; charset=utf-8";
}
return "application/octet-stream";
}
export async function GET(
_request: Request,
context: { params: Promise<{ asset?: string[] }> },
) {
const params = await context.params;
const asset = params.asset ?? [];
if (asset.length === 1 && asset[0] === "manifest.json") {
try {
const manifest = await buildManifest();
return NextResponse.json(manifest, {
headers: { "Cache-Control": "no-store" },
});
} catch (error) {
return NextResponse.json(
{
error: "无法生成 tree shell runtime 清单",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
);
}
}
const relativePath = sanitizeRelativePath(asset);
if (!relativePath) {
return NextResponse.json({ error: "非法 tree shell runtime 资源路径" }, { status: 400 });
}
const generatedRoot = resolveGeneratedRoot();
const absolutePath = path.resolve(generatedRoot, relativePath);
if (!absolutePath.startsWith(generatedRoot + path.sep)) {
return NextResponse.json({ error: "越界访问 tree shell runtime 资源被拒绝" }, { status: 403 });
}
try {
const fileContent = await fs.readFile(absolutePath);
return new NextResponse(fileContent, {
status: 200,
headers: {
"Content-Type": guessContentType(absolutePath),
"Cache-Control": "no-store",
},
});
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return NextResponse.json({ error: "tree shell runtime 资源不存在" }, { status: 404 });
}
return NextResponse.json(
{
error: "读取 tree shell runtime 资源失败",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
);
}
}
@@ -1233,7 +1233,7 @@ describe("/api/tree/commands route", () => {
);
});
it("embed action 走 tree.node.embed,并生成 pageReference 保存 payload", async () => {
it("embed action 走 tree.node.embed,并 pageReference 组装交给 Rust Page Aggregate preflight", async () => {
const client = {
mutation: vi.fn(),
query: vi.fn(async (name: string, args: { id: string }) => {
@@ -1342,18 +1342,19 @@ describe("/api/tree/commands route", () => {
targetDocumentId: "doc_target",
revision: 7,
conflictDetectionKey: "doc_target:7",
content: [
{ id: "anchor_1", type: "paragraph" },
{
id: expect.any(String),
type: "pageReference",
props: {
pageId: "doc_source",
title: "来源页面",
},
},
],
}),
preflightData: {
pageAggregateEmbed: {
sourceDocumentId: "doc_source",
sourceTitle: "来源页面",
targetDocumentId: "doc_target",
targetContent: [
{ id: "anchor_1", type: "paragraph" },
],
anchorBlockId: "anchor_1",
blockId: expect.any(String),
},
},
}),
);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
@@ -1,10 +1,8 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import type { Json } from "@/types/supabase";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import {
assertDocumentId,
assertTitle,
@@ -19,7 +17,6 @@ import {
copyMindmapFilesIfExists,
ensureDocumentScaffold,
} from "@/lib/documents/page-lifecycle-side-effects";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
import {
executeRustBridgeMutationTransport,
@@ -591,32 +588,9 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
}
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
const currentBlocks = extractBlocksFromContent(targetContent.content);
const anchorId = trimOrNull(
(targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id,
);
const anchorIndex = anchorId
? currentBlocks.findIndex(
(block) =>
typeof block === "object" &&
block !== null &&
String((block as { id?: string }).id ?? "") === anchorId,
)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
const nextBlocks: Json[] = [
...currentBlocks.slice(0, insertIndex),
{
id: randomUUID(),
type: "pageReference",
props: {
pageId: sourceId,
title: sourceDoc.title ?? "无标题",
},
},
...currentBlocks.slice(insertIndex),
];
const nextContent = composeContentWithBlocks(targetContent.content, nextBlocks);
const workspaceId =
trimOrNull(sourceDoc.workspace_id) ??
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
@@ -629,24 +603,30 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
workspaceId,
commandName: "tree.node.embed",
payload: {
...buildDocumentSavePayload({
documentId: targetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
content: nextContent,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
blockCount: nextBlocks.length,
}),
documentId: targetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
sourceDocumentId: sourceId,
targetDocumentId: targetId,
anchorBlockId: anchorId,
},
preflightData: {
pageAggregateEmbed: {
sourceDocumentId: sourceId,
sourceTitle: sourceDoc.title ?? "无标题",
targetDocumentId: targetId,
targetContent: targetContent.content,
anchorBlockId: anchorId,
blockId: randomUUID(),
},
},
pageId: targetId,
client,
});
@@ -98,6 +98,23 @@ describe("/api/tree/projections/file route", () => {
},
],
edges: [],
meta: {
search: {
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
schemaVersion: 1,
source: "kernel.project_view",
status: "visible",
requestKey: "page_root:预算",
indexedResourceKinds: ["document", "index", "asset"],
visibleResourceKinds: ["document", "index", "asset"],
metrics: {
visibleRows: 2,
visibleEdges: 0,
},
},
},
},
});
mockDocumentBridgeErrorResponse.mockClear();
});
@@ -130,6 +147,15 @@ describe("/api/tree/projections/file route", () => {
result: {
projection: "file_tree",
rootNodeId: "page_root",
meta: {
search: {
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
status: "visible",
requestKey: "page_root:预算",
},
},
},
},
});
expect(body.result.items.map((item: { rowId: string }) => item.rowId)).toEqual([
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockResolveMnoteWebInternalUrl = vi.fn();
const mockBuildForwardHeaders = vi.fn();
const mockFetch = vi.fn();
vi.mock("@/lib/mnote-web/internal-url", () => ({
resolveMnoteWebInternalUrl: (...args: unknown[]) => mockResolveMnoteWebInternalUrl(...args),
}));
vi.mock("@/lib/server/forward-headers", () => ({
buildForwardHeaders: (...args: unknown[]) => mockBuildForwardHeaders(...args),
}));
describe("/api/tree/runtime/reduce route", () => {
beforeEach(() => {
vi.resetModules();
mockResolveMnoteWebInternalUrl.mockReset();
mockBuildForwardHeaders.mockReset();
mockFetch.mockReset();
vi.stubGlobal("fetch", mockFetch);
});
it("通过 3000 同源 POST 代理到 mnote-web runtime reduce,并原样转发 JSON body 与来源 header", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
cookie: "session=abc",
authorization: "Bearer test-token",
}),
);
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ ok: true, revision: 7 }), {
status: 200,
statusText: "OK",
headers: {
"content-type": "application/json; charset=utf-8",
"set-cookie": "debug=1",
connection: "keep-alive",
"transfer-encoding": "chunked",
"x-upstream": "mnote-web-runtime",
},
}),
);
const { POST } = await import("./route");
const body = JSON.stringify({
workspaceId: "ws_body",
op: "tree.node.rename",
payload: { documentId: "doc_1", title: "新标题" },
});
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce?workspaceId=ws_query", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
cookie: "session=abc",
},
body,
}),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/tree/runtime/reduce?workspaceId=ws_query",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body,
cache: "no-store",
redirect: "manual",
}),
);
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("cookie")).toBe("session=abc");
expect(fetchHeaders.get("authorization")).toBe("Bearer test-token");
expect(fetchHeaders.get("content-type")).toBe("application/json");
expect(fetchHeaders.get("accept")).toBe("application/json");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next_tree_runtime_reduce_proxy");
expect(fetchHeaders.get("x-mnote-source-client")).toBe("wolai-frontend");
expect(fetchHeaders.get("x-mnote-workspace-id")).toBe("ws_query");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("connection")).toBeNull();
expect(response.headers.get("transfer-encoding")).toBeNull();
expect(response.headers.get("x-upstream")).toBe("mnote-web-runtime");
expect(await response.json()).toEqual({ ok: true, revision: 7 });
});
it("没有 workspaceId query 时使用 x-mnote-workspace-id header fallback", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
"x-mnote-workspace-id": "ws_header",
}),
);
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 202,
headers: {
"content-type": "application/json",
},
}),
);
const { POST } = await import("./route");
await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce", {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-workspace-id": "ws_header",
},
body: JSON.stringify({ op: "noop" }),
}),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/tree/runtime/reduce",
expect.objectContaining({
method: "POST",
}),
);
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("x-mnote-workspace-id")).toBe("ws_header");
});
it("上游调用异常时返回 502 JSON", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(new Headers());
mockFetch.mockRejectedValue(new Error("runtime reduce unavailable"));
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ op: "noop" }),
}),
);
expect(response.status).toBe(502);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({
error: "runtime reduce unavailable",
});
});
});
@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
import { buildForwardHeaders } from "@/lib/server/forward-headers";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const stripHopByHopHeaders = (headers: Headers) => {
// 说明:代理响应不应继续透传 hop-by-hop headers,避免浏览器拿到无效连接语义。
const hopByHopHeaders = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"content-length",
];
hopByHopHeaders.forEach((name) => headers.delete(name));
};
export async function POST(request: Request) {
try {
const requestUrl = new URL(request.url);
const internalBaseUrl = await resolveMnoteWebInternalUrl();
const targetUrl = new URL("/api/tree/runtime/reduce", `${internalBaseUrl}/`);
targetUrl.search = requestUrl.search;
const headers = await buildForwardHeaders(request);
headers.set("content-type", "application/json");
headers.set("accept", "application/json");
headers.set("x-mnote-source-channel", "next_tree_runtime_reduce_proxy");
headers.set("x-mnote-source-client", "wolai-frontend");
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (workspaceId && !headers.has("x-mnote-workspace-id")) {
headers.set("x-mnote-workspace-id", workspaceId);
}
const upstream = await fetch(targetUrl.toString(), {
method: "POST",
headers,
body: await request.text(),
cache: "no-store",
redirect: "manual",
});
const body = await upstream.arrayBuffer();
const responseHeaders = new Headers(upstream.headers);
stripHopByHopHeaders(responseHeaders);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
return new NextResponse(body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
} catch (error) {
const message = error instanceof Error ? error.message : "tree runtime reduce 代理失败";
return NextResponse.json(
{
error: message,
},
{
status: 502,
headers: {
"cache-control": "no-store",
},
},
);
}
}