Files
mnote/wolai-frontend/src/lib/documents/bridge.ts
T

507 lines
16 KiB
TypeScript
Raw Normal View History

2026-04-14 13:22:29 +08:00
import { randomUUID } from "crypto";
import type { ConvexHttpClient } from "convex/browser";
2026-04-14 13:22:29 +08:00
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { apiErrorResponse } from "@/lib/api-utils";
import type { PageOptionsState } from "@/types/page-options";
export type BridgeActor = {
actorType: string;
actorId: string;
sessionId: string | null;
};
export type BridgeSource = {
channel: string;
client: string;
};
export type BridgeTarget = {
workspaceId?: string | null;
pageId?: string | null;
blockId?: string | null;
};
export type BridgeRequestMeta = {
idempotencyKey: string | null;
validateOnly: boolean;
dryRun: boolean;
};
export type BridgeContext = {
deploymentId: string | null;
projectId: string | null;
workspaceId: string | null;
requestId: string;
traceId: string;
actor: BridgeActor;
source: BridgeSource;
tenantId: string | null;
authToken: string | null;
idempotencyKey: string | null;
validateOnly: boolean;
dryRun: boolean;
};
export type BridgeErrorCode =
| "VALIDATION_ERROR"
| "UNAUTHORIZED"
| "FORBIDDEN"
| "NOT_FOUND"
| "TRANSPORT_ERROR"
| "REJECTED";
export class DocumentBridgeError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code: BridgeErrorCode,
public readonly details?: unknown,
) {
super(message);
this.name = "DocumentBridgeError";
}
}
export type CommandEnvelope<T> = {
name: string;
commandId: string;
idempotencyKey: string | null;
actor: BridgeActor;
source: BridgeSource;
target: BridgeTarget | null;
payload: T;
reason: string | null;
refs: string[];
dryRun: boolean;
validateOnly: boolean;
};
export type QueryEnvelope<T> = {
name: string;
payload: T;
};
export type DocumentBridgeMutationRequest<
TArgs extends Record<string, unknown> = Record<string, unknown>,
> = {
functionName: string;
deploymentId: string | null;
projectId: string | null;
workspaceId: string | null;
requestId: string;
traceId: string;
idempotencyKey: string | null;
actorId: string;
payloadJson: string;
args: TArgs;
};
const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.title.update": "documents:updateTitle",
"documents.stats.update": "documents:updateStats",
"documents.options.update": "documents:updateOptions",
"documents.save": "documents:updateContent",
"media.assets.replace_storage": "mediaAssets:replaceStorageFromUpload",
} as const satisfies Record<string, string>;
2026-04-14 13:22:29 +08:00
function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null {
for (const candidate of candidates) {
const value = headerList.get(candidate);
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function toBooleanFlag(raw: string | null): boolean {
if (!raw) return false;
return raw === "1" || raw.toLowerCase() === "true";
}
function makeFallbackId(prefix: string): string {
return `${prefix}_${randomUUID()}`;
}
export async function buildDocumentBridgeContext(input: {
request: Request;
workspaceId?: string | null;
idempotencyKey?: string | null;
validateOnly?: boolean;
dryRun?: boolean;
}): Promise<BridgeContext> {
let auth;
try {
auth = await requireAuthContext();
} catch (error) {
if (error instanceof HttpError) {
throw new DocumentBridgeError(error.message || "未登录", error.status, "UNAUTHORIZED");
}
throw new DocumentBridgeError("未登录", 401, "UNAUTHORIZED");
}
const headerList = input.request.headers;
const requestId =
readHeaderValue(headerList, "x-request-id", "x-mnote-request-id") ?? makeFallbackId("req");
const traceId =
readHeaderValue(headerList, "x-trace-id", "x-mnote-trace-id", "x-request-id") ?? makeFallbackId("trace");
const sessionId = readHeaderValue(headerList, "x-session-id", "x-mnote-session-id");
const sourceChannel = readHeaderValue(headerList, "x-source-channel") ?? "next-route";
const sourceClient = readHeaderValue(headerList, "x-source-client", "user-agent") ?? "wolai-frontend";
const deploymentId =
readHeaderValue(headerList, "x-deployment-id") ?? process.env.VERCEL_DEPLOYMENT_ID ?? null;
const projectId = readHeaderValue(headerList, "x-project-id") ?? process.env.VERCEL_PROJECT_ID ?? null;
const tenantId = readHeaderValue(headerList, "x-tenant-id");
const authToken = readHeaderValue(headerList, "authorization");
const idempotencyKey =
input.idempotencyKey ?? readHeaderValue(headerList, "idempotency-key", "x-idempotency-key");
const validateOnly = input.validateOnly ?? toBooleanFlag(readHeaderValue(headerList, "x-validate-only"));
const dryRun = input.dryRun ?? toBooleanFlag(readHeaderValue(headerList, "x-dry-run"));
return {
deploymentId,
projectId,
workspaceId: input.workspaceId ?? null,
requestId,
traceId,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId,
},
source: {
channel: sourceChannel,
client: sourceClient,
},
tenantId,
authToken,
idempotencyKey,
validateOnly,
dryRun,
};
}
export function buildDocumentBridgeContextWithActor(input: {
request: Request;
actor: BridgeActor;
workspaceId?: string | null;
idempotencyKey?: string | null;
validateOnly?: boolean;
dryRun?: boolean;
source?: Partial<BridgeSource>;
authToken?: string | null;
}): BridgeContext {
const headerList = input.request.headers;
const requestId =
readHeaderValue(headerList, "x-request-id", "x-mnote-request-id") ?? makeFallbackId("req");
const traceId =
readHeaderValue(headerList, "x-trace-id", "x-mnote-trace-id", "x-request-id") ?? makeFallbackId("trace");
const sourceChannel = input.source?.channel?.trim() || readHeaderValue(headerList, "x-source-channel") || "next-route";
const sourceClient =
input.source?.client?.trim() ||
readHeaderValue(headerList, "x-source-client", "user-agent") ||
"wolai-frontend";
const deploymentId =
readHeaderValue(headerList, "x-deployment-id") ?? process.env.VERCEL_DEPLOYMENT_ID ?? null;
const projectId = readHeaderValue(headerList, "x-project-id") ?? process.env.VERCEL_PROJECT_ID ?? null;
const tenantId = readHeaderValue(headerList, "x-tenant-id");
const authToken = input.authToken ?? readHeaderValue(headerList, "authorization");
const idempotencyKey =
input.idempotencyKey ?? readHeaderValue(headerList, "idempotency-key", "x-idempotency-key");
const validateOnly = input.validateOnly ?? toBooleanFlag(readHeaderValue(headerList, "x-validate-only"));
const dryRun = input.dryRun ?? toBooleanFlag(readHeaderValue(headerList, "x-dry-run"));
return {
deploymentId,
projectId,
workspaceId: input.workspaceId ?? null,
requestId,
traceId,
actor: input.actor,
source: {
channel: sourceChannel,
client: sourceClient,
},
tenantId,
authToken,
idempotencyKey,
validateOnly,
dryRun,
};
}
2026-04-14 13:22:29 +08:00
export function buildDocumentCommandEnvelope<T>(input: {
name: string;
payload: T;
context: BridgeContext;
target?: BridgeTarget | null;
reason?: string | null;
refs?: string[];
}): CommandEnvelope<T> {
return {
name: input.name,
commandId: makeFallbackId("cmd"),
idempotencyKey: input.context.idempotencyKey,
actor: input.context.actor,
source: input.context.source,
target: input.target ?? null,
payload: input.payload,
reason: input.reason ?? null,
refs: input.refs ?? [],
dryRun: input.context.dryRun,
validateOnly: input.context.validateOnly,
};
}
export function buildDocumentQueryEnvelope<T>(input: { name: string; payload: T }): QueryEnvelope<T> {
return {
name: input.name,
payload: input.payload,
};
}
function getDocumentBridgeMutationFunctionName(commandName: string): string {
const functionName =
DOCUMENT_BRIDGE_MUTATION_FUNCTIONS[
commandName as keyof typeof DOCUMENT_BRIDGE_MUTATION_FUNCTIONS
];
if (!functionName) {
throw new DocumentBridgeError(
`未注册文档 bridge mutation: ${commandName}`,
500,
"TRANSPORT_ERROR",
);
}
return functionName;
}
function buildDocumentCommandPayloadJson(input: {
context: BridgeContext;
commandName: string;
workspaceId: string | null;
idempotencyKey: string | null;
}): string {
return JSON.stringify({
kind: "command",
name: input.commandName,
request_id: input.context.requestId,
trace_id: input.context.traceId,
deployment_id: input.context.deploymentId,
project_id: input.context.projectId,
workspace_id: input.workspaceId,
tenant_id: input.context.tenantId,
idempotency_key: input.idempotencyKey,
actor: {
type: input.context.actor.actorType,
id: input.context.actor.actorId,
session_id: input.context.actor.sessionId,
},
source: {
channel: input.context.source.channel,
client: input.context.source.client,
},
});
}
export function buildDocumentBridgeMutationRequest<
TPayload,
TArgs extends Record<string, unknown>,
>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
mapConvexArgs: (payload: TPayload) => TArgs;
}): DocumentBridgeMutationRequest<TArgs> {
const workspaceId = input.envelope.target?.workspaceId ?? input.context.workspaceId ?? null;
const idempotencyKey = input.envelope.idempotencyKey ?? input.context.idempotencyKey;
return {
functionName: getDocumentBridgeMutationFunctionName(input.envelope.name),
deploymentId: input.context.deploymentId,
projectId: input.context.projectId,
workspaceId,
requestId: input.context.requestId,
traceId: input.context.traceId,
idempotencyKey,
actorId: input.context.actor.actorId,
payloadJson: buildDocumentCommandPayloadJson({
context: input.context,
commandName: input.envelope.name,
workspaceId,
idempotencyKey,
}),
args: input.mapConvexArgs(input.envelope.payload),
};
}
export async function executeDocumentBridgeMutationRequest<
TArgs extends Record<string, unknown>,
TResult,
>(input: {
client: ConvexHttpClient;
mutation: unknown;
request: DocumentBridgeMutationRequest<TArgs>;
}): Promise<TResult> {
const mutate = input.client.mutation.bind(input.client) as (
mutation: unknown,
args: TArgs,
) => Promise<TResult>;
return mutate(input.mutation, input.request.args);
}
2026-04-14 13:22:29 +08:00
export function assertDocumentId(documentId: string | null | undefined): string {
const normalized = typeof documentId === "string" ? documentId.trim() : "";
if (!normalized) {
throw new DocumentBridgeError("缺少 documentId", 400, "VALIDATION_ERROR", [
{ field: "documentId", reason: "required" },
]);
}
return normalized;
}
export function assertTitle(title: string | null | undefined): string {
if (typeof title !== "string") {
throw new DocumentBridgeError("缺少 title", 400, "VALIDATION_ERROR", [
{ field: "title", reason: "required" },
]);
}
const normalized = title.trim() || "无标题";
return normalized;
}
export function assertBlockId(blockId: string | null | undefined): string {
const normalized = typeof blockId === "string" ? blockId.trim() : "";
if (!normalized) {
throw new DocumentBridgeError("缺少 blockId", 400, "VALIDATION_ERROR", [
{ field: "blockId", reason: "required" },
]);
}
return normalized;
}
export function assertNextBlock(nextBlock: unknown): asserts nextBlock is Record<string, unknown> {
if (!nextBlock || typeof nextBlock !== "object" || Array.isArray(nextBlock)) {
throw new DocumentBridgeError("缺少 nextBlock", 400, "VALIDATION_ERROR", [
{ field: "nextBlock", reason: "required object" },
]);
}
}
export function assertStats(stats: unknown): asserts stats is {
wordCount: number;
characterCount: number;
blockCount: number;
todoTotal: number;
todoDone: number;
} {
if (!stats || typeof stats !== "object") {
throw new DocumentBridgeError("缺少 stats", 400, "VALIDATION_ERROR", [
{ field: "stats", reason: "required" },
]);
}
const record = stats as Record<string, unknown>;
const fields = ["wordCount", "characterCount", "blockCount", "todoTotal", "todoDone"] as const;
for (const field of fields) {
if (typeof record[field] !== "number" || !Number.isFinite(record[field] as number)) {
throw new DocumentBridgeError("stats 字段非法", 400, "VALIDATION_ERROR", [
{ field, reason: "must be finite number" },
]);
}
}
}
const PAGE_FONT_VALUES = new Set<PageOptionsState["pageFont"]>(["default", "song", "kai"]);
const PAGE_LAYOUT_DENSITY_VALUES = new Set<PageOptionsState["layoutDensity"]>(["compact", "normal", "spacious"]);
const PAGE_OPTION_BOOLEAN_FIELDS = [
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"showStructure",
"protectEditing",
"showWordCount",
"collapseBacklinks",
"hideChildPages",
"showBlockRefCount",
] as const satisfies readonly (keyof PageOptionsState)[];
const PAGE_OPTION_ALLOWED_FIELDS = new Set<keyof PageOptionsState>([
...PAGE_OPTION_BOOLEAN_FIELDS,
"pageFont",
"layoutDensity",
"embedDefaultBlockId",
]);
export function assertOptionsPatch(
options: unknown,
): asserts options is Partial<Pick<PageOptionsState, keyof PageOptionsState>> {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new DocumentBridgeError("缺少 options", 400, "VALIDATION_ERROR", [
{ field: "options", reason: "required object" },
]);
}
const record = options as Record<string, unknown>;
const entries = Object.entries(record);
if (entries.length === 0) {
throw new DocumentBridgeError("缺少 options", 400, "VALIDATION_ERROR", [
{ field: "options", reason: "must not be empty" },
]);
}
for (const [field, value] of entries) {
if (!PAGE_OPTION_ALLOWED_FIELDS.has(field as keyof PageOptionsState)) {
throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [
{ field, reason: "unexpected field" },
]);
}
if ((PAGE_OPTION_BOOLEAN_FIELDS as readonly string[]).includes(field)) {
if (typeof value !== "boolean") {
throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [
{ field, reason: "must be boolean" },
]);
}
continue;
}
if (field === "pageFont") {
if (typeof value !== "string" || !PAGE_FONT_VALUES.has(value as PageOptionsState["pageFont"])) {
throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [
{ field, reason: "must be one of default/song/kai" },
]);
}
continue;
}
if (field === "layoutDensity") {
if (
typeof value !== "string" ||
!PAGE_LAYOUT_DENSITY_VALUES.has(value as PageOptionsState["layoutDensity"])
) {
throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [
{ field, reason: "must be one of compact/normal/spacious" },
]);
}
continue;
}
if (field === "embedDefaultBlockId" && value !== null && typeof value !== "string") {
throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [
{ field, reason: "must be string or null" },
]);
}
}
}
export function documentBridgeErrorResponse(error: unknown) {
if (error instanceof DocumentBridgeError) {
return apiErrorResponse(error.message, error.status, {
code: error.code,
details: error.details,
});
}
if (error instanceof HttpError) {
return apiErrorResponse(error.message || "未登录", error.status);
}
return apiErrorResponse(error instanceof Error ? error.message : "服务器错误", 500);
}