fix: запуск занятия по HTTP в локальной сети без crypto.randomUUID

This commit is contained in:
gglamer 2026-09-29 11:06:19 +00:00
commit 92b4684871
4 changed files with 34 additions and 5 deletions

View file

@ -5,7 +5,7 @@ import { transform } from "esbuild";
const source = await readFile(new URL("../src/shared/api/ws.ts", import.meta.url), "utf8"); const source = await readFile(new URL("../src/shared/api/ws.ts", import.meta.url), "utf8");
const { code } = await transform(source, { loader: "ts", format: "esm" }); const { code } = await transform(source, { loader: "ts", format: "esm" });
const moduleUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`; const moduleUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`;
const { callChannel, stationChannel } = await import(moduleUrl); const { callChannel, randomUuid, stationChannel } = await import(moduleUrl);
const mergeSource = await readFile(new URL("../src/features/kio-card/merge.ts", import.meta.url), "utf8"); const mergeSource = await readFile(new URL("../src/features/kio-card/merge.ts", import.meta.url), "utf8");
const { code: mergeCode } = await transform(mergeSource, { loader: "ts", format: "esm" }); const { code: mergeCode } = await transform(mergeSource, { loader: "ts", format: "esm" });
const { applyPatch, applyState, edit, empty } = await import( const { applyPatch, applyState, edit, empty } = await import(
@ -119,4 +119,19 @@ socket.receive({ type: "station.state", snapshot: { card_id: "card-1", reply_tex
socket = reconnect(socket); socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "matching persisted station snapshot clears the reply"); assert.deepEqual(socket.sent, [], "matching persisted station snapshot clears the reply");
// Стенд по http://<IP> — не защищённый контекст: randomUUID у браузера нет.
const nativeRandomUuid = Object.getOwnPropertyDescriptor(globalThis.crypto, "randomUUID");
Object.defineProperty(globalThis.crypto, "randomUUID", { value: undefined, configurable: true });
try {
const ids = Array.from({ length: 200 }, () => randomUuid());
for (const id of ids) {
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, "UUID v4 without randomUUID");
}
assert.equal(new Set(ids).size, ids.length, "UUIDs without randomUUID do not repeat");
} finally {
if (nativeRandomUuid) Object.defineProperty(globalThis.crypto, "randomUUID", nativeRandomUuid);
else delete globalThis.crypto.randomUUID;
}
assert.match(randomUuid(), /^[0-9a-f-]{36}$/, "native randomUUID is used when present");
console.log("WebSocket durable outbox replay/ack tests passed"); console.log("WebSocket durable outbox replay/ack tests passed");

View file

@ -5,6 +5,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { randomUuid } from "@/shared/api/ws";
import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated"; import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated";
const DDS_CODES: ErrorCode[] = ["D1", "D2", "D3", "D4", "D5", "D6"]; const DDS_CODES: ErrorCode[] = ["D1", "D2", "D3", "D4", "D5", "D6"];
@ -77,7 +78,7 @@ export function FindingReview({ report, onChange }: {
setError("Для отметки нужны факт и норма."); setError("Для отметки нужны факт и норма.");
return; return;
} }
pendingId.current ??= crypto.randomUUID(); pendingId.current ??= randomUuid();
const clientId = pendingId.current; const clientId = pendingId.current;
void run(async () => { void run(async () => {
const updated = await send(base, { const updated = await send(base, {

View file

@ -25,6 +25,7 @@ import {
} from "@/shared/api/http"; } from "@/shared/api/http";
import type { Signal } from "@/shared/api/http"; import type { Signal } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session"; import { sessionIdFromUrl } from "@/shared/api/session";
import { randomUuid } from "@/shared/api/ws";
import type { Level, SessionMode, SessionReport } from "@/shared/types/generated"; import type { Level, SessionMode, SessionReport } from "@/shared/types/generated";
const MODES: { value: SessionMode; label: string; hint: string }[] = [ const MODES: { value: SessionMode; label: string; hint: string }[] = [
@ -214,7 +215,7 @@ export function Instructor() {
function startLesson() { function startLesson() {
if (startPendingId || toStart.current || startDisabledReason) return; if (startPendingId || toStart.current || startDisabledReason) return;
if (trainees.data?.length && (!traineeId || (traineeId === "__demo__" && !DEMO_MODE))) return; if (trainees.data?.length && (!traineeId || (traineeId === "__demo__" && !DEMO_MODE))) return;
const id = crypto.randomUUID(); const id = randomUuid();
// Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой. // Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой.
window.history.replaceState(null, "", `/instructor?session=${id}`); window.history.replaceState(null, "", `/instructor?session=${id}`);
toStart.current = id; toStart.current = id;

View file

@ -34,6 +34,18 @@ const BUFFER_MAX_ITEMS = 100;
interface Buffered<Out> { key: string; commandId: string; event: Out; at: number } interface Buffered<Out> { key: string; commandId: string; event: Out; at: number }
/** UUID v4 для номера занятия и команд. `crypto.randomUUID` браузер отдаёт только
* в защищённом контексте (HTTPS, localhost): стенд по `http://<IP>` в классе
* его не видит, и запуск занятия молча падал. `getRandomValues` есть везде. */
export function randomUuid(): string {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
export class Channel<In extends { type: string }, Out extends { type: string }> { export class Channel<In extends { type: string }, Out extends { type: string }> {
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
private attempt = 0; private attempt = 0;
@ -127,7 +139,7 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
existing.at = Date.now(); existing.at = Date.now();
pending = existing; pending = existing;
} else { } else {
pending = { key, commandId: crypto.randomUUID(), event, at: Date.now() }; pending = { key, commandId: randomUuid(), event, at: Date.now() };
this.buffered.push(pending); this.buffered.push(pending);
if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift(); if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift();
} }
@ -196,7 +208,7 @@ export const stationChannel = (
if (event.type === "card.reply") return `card.reply:${event.card_id}`; if (event.type === "card.reply") return `card.reply:${event.card_id}`;
// Non-replacement station actions are each their own replayable command. // Non-replacement station actions are each their own replayable command.
// The server deduplicates by this ID inside its persisted session snapshot. // The server deduplicates by this ID inside its persisted session snapshot.
return `command:${crypto.randomUUID()}`; return `command:${randomUuid()}`;
}, },
isAcknowledged: (incoming, pending) => incoming.type === "station.state" isAcknowledged: (incoming, pending) => incoming.type === "station.state"
&& pending.type === "card.reply" && pending.type === "card.reply"