主编辑区改造准备

This commit is contained in:
lix-2026
2026-04-21 06:26:35 +08:00
parent 1e686bfa3c
commit 5d1c94eb9e
49 changed files with 6730 additions and 2565 deletions
@@ -15,14 +15,18 @@ import {
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const body = await request.json() as Partial<DocumentSavePayload> & { content: unknown };
const body = await request.json() as Partial<DocumentSavePayload> & { content?: unknown };
const normalizedDocumentId = assertDocumentId(body.documentId);
const payload = buildDocumentSavePayload({
documentId: normalizedDocumentId,
workspaceId: body.workspaceId,
revision: body.revision,
content: body.content as DocumentSavePayload["content"],
editorDocument: body.editorDocument,
content: body.content as DocumentSavePayload["content"] | undefined,
tiptapDocument: body.tiptapDocument,
conflictDetectionKey: body.conflictDetectionKey,
snapshotCapturedAt: body.snapshotCapturedAt,
blockCount: body.blockCount,
});
const normalizedWorkspaceId = payload.workspaceId;
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
@@ -0,0 +1,169 @@
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";
type RuntimeManifest = {
bridgeRuntimePath: string | null;
extensionModulePaths: string[];
generatedRootPath: string | null;
entryScriptPath: string | null;
wasmPath: string | null;
};
const DIST_ROOT = path.resolve(process.cwd(), "..", "rust", "spikes", "leptos-tiptap-spike", "dist");
const ENTRY_SCRIPT_PATTERN = /^mnote-leptos-tiptap-spike-.*\.js$/;
const WASM_PATTERN = /^mnote-leptos-tiptap-spike-.*_bg\.wasm$/;
const EXTENSION_MODULE_PATTERN = /^tiptap_[a-z0-9_]+\.js$/;
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
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 walkFiles(rootDir: string): Promise<string[]> {
const output: string[] = [];
const queue: string[] = [rootDir];
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
continue;
}
const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
queue.push(absolute);
continue;
}
if (!entry.isFile()) {
continue;
}
const relative = path.relative(rootDir, absolute);
output.push(toPosixPath(relative));
}
}
return output.sort((a, b) => a.localeCompare(b));
}
async function buildRuntimeManifest(): Promise<RuntimeManifest> {
const files = await walkFiles(DIST_ROOT);
const bridgeRuntimePath = files.find((item) => item.endsWith("/bridge_runtime.js") || item === "bridge_runtime.js") ?? null;
const generatedRootPath = bridgeRuntimePath ? path.posix.dirname(bridgeRuntimePath) : null;
const extensionModulePaths = generatedRootPath
? files.filter((item) => {
if (!item.startsWith(`${generatedRootPath}/`)) {
return false;
}
const basename = path.posix.basename(item);
return EXTENSION_MODULE_PATTERN.test(basename);
})
: [];
const entryScriptPath =
files.find((item) => ENTRY_SCRIPT_PATTERN.test(path.posix.basename(item))) ?? null;
const wasmPath = files.find((item) => WASM_PATTERN.test(path.posix.basename(item))) ?? null;
return {
bridgeRuntimePath,
extensionModulePaths,
generatedRootPath,
entryScriptPath,
wasmPath,
};
}
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";
}
if (extension === ".html") {
return "text/html; charset=utf-8";
}
if (extension === ".css") {
return "text/css; 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 buildRuntimeManifest();
return NextResponse.json(manifest, {
headers: { "Cache-Control": "no-store" },
});
} catch (error) {
return NextResponse.json(
{
error: "无法生成 leptos-tiptap runtime 清单",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
);
}
}
const relativePath = sanitizeRelativePath(asset);
if (!relativePath) {
return NextResponse.json({ error: "非法 runtime 资源路径" }, { status: 400 });
}
const absolutePath = path.resolve(DIST_ROOT, relativePath);
if (!absolutePath.startsWith(DIST_ROOT + path.sep)) {
return NextResponse.json({ error: "越界访问 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: "runtime 资源不存在" }, { status: 404 });
}
return NextResponse.json(
{
error: "读取 runtime 资源失败",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
);
}
}