Files
openmaic/OpenMAIC/lib/server/course-publish-transport.ts
2026-08-16 14:58:47 +08:00

220 lines
6.4 KiB
TypeScript

import type { CourseManifestRecord } from '@/lib/course-manifest-repo/types';
import {
COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV,
COURSE_PUBLISH_SERVER_BASE_URL_ENV,
COURSE_PUBLISH_TIMEOUT_MS_ENV,
CoursePublishTransportError,
DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES,
DEFAULT_COURSE_PUBLISH_TIMEOUT_MS,
MAX_COURSE_PUBLISH_METADATA_BYTES,
parseRemoteCoursePublishMetadata,
positiveIntegerEnv,
resolveCoursePublishEndpoint,
type RemoteCoursePublishErrorBody,
type RemoteCoursePublishMetadata,
type RemoteCoursePublishSuccessBody,
} from '@/lib/server/course-publish-contract';
const MAX_RESPONSE_BYTES = 1024 * 1024;
export interface RemoteCoursePublishUpload {
index: number;
zipBytes: Uint8Array;
}
export interface PublishCourseToServerOptions {
metadata: RemoteCoursePublishMetadata;
archives: RemoteCoursePublishUpload[];
baseUrl?: string;
token?: string | null;
fetchImpl?: typeof fetch;
}
export interface PublishCourseToServerResult {
record: CourseManifestRecord;
idempotent: boolean;
}
function isErrorBody(value: unknown): value is RemoteCoursePublishErrorBody {
if (!value || typeof value !== 'object') return false;
const body = value as Partial<RemoteCoursePublishErrorBody>;
return (
body.success === false &&
typeof body.errorCode === 'string' &&
typeof body.error === 'string' &&
(body.phase === 'request' ||
body.phase === 'staging' ||
body.phase === 'commit' ||
body.phase === 'rollback')
);
}
async function readResponseJson(response: Response): Promise<unknown> {
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > MAX_RESPONSE_BYTES) {
throw new CoursePublishTransportError(
'UPSTREAM_RESPONSE_INVALID',
'Course publish server response exceeds the size limit',
'request',
502,
);
}
try {
return JSON.parse(text) as unknown;
} catch {
throw new CoursePublishTransportError(
'UPSTREAM_RESPONSE_INVALID',
'Course publish server returned invalid JSON',
'request',
502,
);
}
}
/** One server-only multipart request carrying course metadata plus every module ZIP. */
export async function publishCourseToServer(
options: PublishCourseToServerOptions,
): Promise<PublishCourseToServerResult> {
const metadata = parseRemoteCoursePublishMetadata(options.metadata);
const configuredBaseUrl =
options.baseUrl?.trim() || process.env[COURSE_PUBLISH_SERVER_BASE_URL_ENV]?.trim();
if (!configuredBaseUrl) {
throw new CoursePublishTransportError(
'SERVER_URL_MISSING',
`${COURSE_PUBLISH_SERVER_BASE_URL_ENV} is not configured`,
'request',
503,
);
}
const token = options.token ?? process.env.COURSEWARE_PUBLISH_TOKEN;
if (!token) {
throw new CoursePublishTransportError(
'PUBLISH_TOKEN_MISSING',
'COURSEWARE_PUBLISH_TOKEN is not configured',
'request',
503,
);
}
const archiveByIndex = new Map(options.archives.map((archive) => [archive.index, archive]));
if (archiveByIndex.size !== metadata.modules.length) {
throw new CoursePublishTransportError(
'MODULE_ARCHIVE_LAYOUT_MISMATCH',
'Module ZIP count does not match metadata',
'request',
400,
);
}
const metadataJson = JSON.stringify(metadata);
const metadataBytes = new TextEncoder().encode(metadataJson).byteLength;
if (metadataBytes > MAX_COURSE_PUBLISH_METADATA_BYTES) {
throw new CoursePublishTransportError(
'METADATA_TOO_LARGE',
'Course publish metadata exceeds the size limit',
'request',
413,
);
}
const maxUploadBytes = positiveIntegerEnv(
COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV,
DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES,
);
const archiveBytes = options.archives.reduce(
(total, archive) => total + archive.zipBytes.byteLength,
0,
);
if (metadataBytes + archiveBytes > maxUploadBytes) {
throw new CoursePublishTransportError(
'COURSE_UPLOAD_TOO_LARGE',
`Course publish exceeds ${maxUploadBytes} bytes`,
'request',
413,
);
}
const form = new FormData();
form.set('metadata', metadataJson);
for (const moduleRecord of metadata.modules) {
const archive = archiveByIndex.get(moduleRecord.index);
if (!archive) {
throw new CoursePublishTransportError(
'MODULE_ARCHIVE_MISSING',
`Module ${moduleRecord.index} ZIP is missing`,
'request',
400,
moduleRecord.index,
);
}
form.set(
`module-${moduleRecord.index}`,
new File([archive.zipBytes as unknown as BlobPart], `module-${moduleRecord.index}.zip`, {
type: 'application/zip',
}),
);
}
const endpoint = resolveCoursePublishEndpoint(configuredBaseUrl);
const timeoutMs = positiveIntegerEnv(
COURSE_PUBLISH_TIMEOUT_MS_ENV,
DEFAULT_COURSE_PUBLISH_TIMEOUT_MS,
);
let response: Response;
try {
response = await (options.fetchImpl ?? fetch)(endpoint, {
method: 'POST',
headers: { authorization: `Bearer ${token}` },
body: form,
redirect: 'error',
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
throw new CoursePublishTransportError(
error instanceof DOMException && error.name === 'TimeoutError'
? 'UPSTREAM_TIMEOUT'
: 'UPSTREAM_UNAVAILABLE',
'Course publish server could not be reached',
'request',
502,
undefined,
error instanceof Error ? error.message : undefined,
);
}
const body = await readResponseJson(response);
if (!response.ok || isErrorBody(body)) {
if (isErrorBody(body)) {
throw new CoursePublishTransportError(
body.errorCode,
body.error,
body.phase,
response.status,
body.moduleIndex,
body.details,
);
}
throw new CoursePublishTransportError(
'UPSTREAM_RESPONSE_INVALID',
`Course publish server failed with HTTP ${response.status}`,
'request',
502,
);
}
const success = body as Partial<RemoteCoursePublishSuccessBody>;
if (
success.success !== true ||
!success.record ||
success.record.schemaVersion !== 2 ||
success.record.courseId !== metadata.courseId ||
typeof success.idempotent !== 'boolean'
) {
throw new CoursePublishTransportError(
'UPSTREAM_RESPONSE_INVALID',
'Course publish server returned an invalid receipt',
'request',
502,
);
}
return { record: success.record, idempotent: success.idempotent };
}