import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken } from './works-square-session'; import type { LearningRuntimeBridgeEvent, LearningRuntimeBridgeRequest, LearningRuntimeCapability, } from '../../shared/learning'; const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; const SHA256_PATTERN = /^[0-9a-f]{64}$/; const REQUEST_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/; const MAX_REQUEST_BYTES = 2 * 1024 * 1024; const MAX_RESPONSE_BYTES = 64 * 1024 * 1024; const CAPABILITIES = new Set([ 'quiz-grade', 'pbl/v2/task/update', 'pbl/v2/open-task', 'pbl/v2/simulator', 'pbl/v2/instructor', 'pbl/v2/evaluate', ]); type Dependencies = { fetchImpl?: typeof fetch; getAccessToken?: typeof getValidWorksSquareAccessToken; apiBaseUrl?: string; }; function capabilityPath(capability: LearningRuntimeCapability): string { return capability.split('/').map(encodeURIComponent).join('/'); } function sanitizeAnchor(value: unknown): { sceneId?: string; sceneOrder?: number } | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const candidate = value as Record; const anchor = { ...(typeof candidate.sceneId === 'string' && candidate.sceneId.length > 0 && candidate.sceneId.length <= 256 ? { sceneId: candidate.sceneId } : {}), ...(Number.isSafeInteger(candidate.sceneOrder) && Number(candidate.sceneOrder) >= 0 ? { sceneOrder: Number(candidate.sceneOrder) } : {}), }; return Object.keys(anchor).length > 0 ? anchor : undefined; } function validateRequest(request: LearningRuntimeBridgeRequest): string { const anchor = sanitizeAnchor(request.context.anchor); if (!REQUEST_ID_PATTERN.test(request.requestId) || !COURSE_ID_PATTERN.test(request.courseId) || !SHA256_PATTERN.test(request.contentHash) || (request.context.moduleId !== null && !MODULE_ID_PATTERN.test(request.context.moduleId)) || !SHA256_PATTERN.test(request.context.moduleContentHash) || !anchor?.sceneId || request.method !== 'POST' || !CAPABILITIES.has(request.capability)) { throw new Error('课堂联网能力请求无效'); } const body = JSON.stringify({ context: { moduleId: request.context.moduleId, moduleContentHash: request.context.moduleContentHash, anchor: { sceneId: anchor.sceneId, ...(anchor.sceneOrder !== undefined ? { sceneOrder: anchor.sceneOrder } : {}) }, }, body: request.body ?? {}, }); if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) throw new Error('课堂联网请求过大'); return body; } export function createLearningRuntimeBridge(dependencies: Dependencies = {}) { const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); async function request( input: LearningRuntimeBridgeRequest, emit: (event: LearningRuntimeBridgeEvent) => void, ): Promise { try { const body = validateRequest(input); const token = await getAccessToken({ fetchImpl }); if (!token) { emit({ requestId: input.requestId, type: 'error', code: 'LEARNING_AUTH_REQUIRED', message: '请先登录后使用联网课堂能力' }); return; } const fetchRequest = (accessToken: string) => fetchImpl( `${apiBaseUrl}/api/learning/courses/${encodeURIComponent(input.courseId)}/runtime/${capabilityPath(input.capability)}`, { method: 'POST', headers: { Accept: '*/*', Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'X-Course-Content-Hash': input.contentHash, 'X-Content-Hash': input.contentHash, }, body, redirect: 'manual', }, ); let response = await fetchRequest(token); if (response.status === 401) { await response.body?.cancel().catch(() => undefined); const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); if (refreshed) response = await fetchRequest(refreshed); } emit({ requestId: input.requestId, type: 'start', status: response.status, contentType: response.headers.get('content-type') || 'application/octet-stream', }); if (response.body) { const reader = response.body.getReader(); let bytesRead = 0; try { while (true) { const chunk = await reader.read(); if (chunk.done) break; bytesRead += chunk.value.byteLength; if (bytesRead > MAX_RESPONSE_BYTES) throw new Error('课堂联网响应过大'); emit({ requestId: input.requestId, type: 'chunk', chunk: new Uint8Array(chunk.value) }); } } finally { reader.releaseLock(); } } emit({ requestId: input.requestId, type: 'end' }); } catch (error) { emit({ requestId: input.requestId, type: 'error', code: 'LEARNING_RUNTIME_UNAVAILABLE', message: error instanceof Error ? error.message : '课堂联网能力暂时不可用', }); } } return { request }; }