220 lines
6.4 KiB
TypeScript
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 };
|
|
}
|