feat(tree): close rust family shell cutover
This commit is contained in:
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user