fix: запуск занятия по HTTP в локальной сети без crypto.randomUUID
This commit is contained in:
parent
35ff14f2ea
commit
92b4684871
4 changed files with 34 additions and 5 deletions
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue