Files
mnote/wolai-frontend/src/app/api/mnote-web/stream/route.ts
T

163 lines
4.5 KiB
TypeScript
Raw Normal View History

2026-04-26 04:29:23 +08:00
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
2026-04-26 04:29:23 +08:00
buildDocumentBridgeContextWithActor,
buildDocumentQueryEnvelope,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
2026-04-26 04:29:23 +08:00
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
import { attachKernelFileTreeProjection, resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
2026-04-24 06:10:18 +08:00
import {
2026-04-26 04:29:23 +08:00
streamTreeFrames,
type TreeStreamOverview,
type TreeStreamSnapshotPayload,
} from "@/lib/tree-stream/server";
export const dynamic = "force-dynamic";
2026-04-26 04:29:23 +08:00
export const runtime = "nodejs";
2026-04-26 04:29:23 +08:00
function readNumberParam(url: URL, name: string): number | null {
const raw = url.searchParams.get(name);
if (!raw?.trim()) {
return null;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function encodeSseFrame(event: string, payload: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
}
export async function GET(request: Request) {
2026-04-26 04:29:23 +08:00
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
2026-04-26 04:29:23 +08:00
const requestUrl = new URL(request.url);
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
2026-04-26 04:29:23 +08:00
const { auth, client } = await getAuthedConvexClient();
const actor = {
actorType: "user",
actorId: auth.userId,
sessionId: null,
};
const context = buildDocumentBridgeContextWithActor({
request,
actor,
workspaceId,
source: {
channel: "next_mnote_web_stream",
client: "wolai-frontend",
},
});
2026-04-26 04:29:23 +08:00
const loadOverview = async (): Promise<TreeStreamOverview> => {
const envelope = buildDocumentQueryEnvelope({
name: "bridge.workspace.overview",
payload: {
workspaceId,
2026-04-26 04:29:23 +08:00
limit: 50,
cursor: null,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
},
});
2026-04-26 04:29:23 +08:00
const plan = await resolveRustBridgeQueryPlan({
context,
2026-04-26 04:29:23 +08:00
envelope,
});
2026-04-26 04:29:23 +08:00
return executeRustBridgeQueryTransport<TreeStreamOverview>({
client,
2026-04-26 04:29:23 +08:00
plan,
});
2026-04-26 04:29:23 +08:00
};
2026-04-26 04:29:23 +08:00
const loadSnapshot = async (): Promise<TreeStreamSnapshotPayload> => {
const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
client,
plan,
});
const datasetWithFileTree = attachKernelFileTreeProjection({
dataset,
projection: await resolveKernelFileTreeProjection({
client,
request,
workspaceId,
actor,
dataset,
rootNodeId: requestUrl.searchParams.get("rootNodeId")?.trim() || null,
depth: readNumberParam(requestUrl, "depth"),
}),
});
return {
requestId: context.requestId,
traceId: context.traceId,
2026-04-26 04:29:23 +08:00
data: datasetWithFileTree,
snapshot: {
2026-04-26 04:29:23 +08:00
dataset: datasetWithFileTree,
},
};
2026-04-26 04:29:23 +08:00
};
2026-04-26 04:29:23 +08:00
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const frame of streamTreeFrames({
workspaceId,
rootNodeId: requestUrl.searchParams.get("rootNodeId"),
initialCursor: requestUrl.searchParams.get("cursor"),
pollMs: readNumberParam(requestUrl, "pollMs") ?? undefined,
maxPolls: readNumberParam(requestUrl, "maxPolls"),
loadOverview,
loadSnapshot,
})) {
if (request.signal.aborted) {
break;
}
controller.enqueue(encoder.encode(encodeSseFrame(frame.event, frame.payload)));
}
controller.close();
} catch (error) {
controller.error(error);
}
},
cancel() {
return undefined;
},
});
return new NextResponse(stream, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
"x-upstream": "next-tree-stream",
},
});
}