主编辑区改造准备
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user