General Upgrade, NEW: WebServers, Better Docs
Some checks failed
CI / checks (push) Failing after 5m18s
Some checks failed
CI / checks (push) Failing after 5m18s
This commit is contained in:
parent
5f11d476b6
commit
6e5c985719
122 changed files with 10309 additions and 5206 deletions
|
|
@ -5,7 +5,7 @@ pub use mtp_type_map as type_map;
|
|||
#[cfg(feature = "crypto")]
|
||||
pub use mtp_crypto as crypto;
|
||||
|
||||
#[cfg(feature = "host")]
|
||||
#[cfg(any(feature = "host", feature = "web-server"))]
|
||||
pub use mtp_host as host;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
|
|
@ -13,3 +13,6 @@ pub use mtp_client as client;
|
|||
|
||||
#[cfg(feature = "files")]
|
||||
pub use mtp_files as files;
|
||||
|
||||
#[cfg(feature = "web-server")]
|
||||
pub use mtp_webserver as webserver;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as bindings from "mtp/raw";
|
||||
import { MTPRatchet } from "./ratchet.js";
|
||||
import type { MTPSessionState } from "./session";
|
||||
import { concatBytes, writeU64BE } from "./utils.js";
|
||||
|
||||
export const MTP_E2EE_VERSION = 1;
|
||||
export const FLAG_INIT = 0x01;
|
||||
|
|
@ -37,18 +38,6 @@ export interface SerializedEncryptedMessage {
|
|||
aeadPayload: Uint8Array;
|
||||
}
|
||||
|
||||
function writeU64BE(value: bigint): Uint8Array {
|
||||
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) {
|
||||
throw new Error("u64 value out of range");
|
||||
}
|
||||
const buf = new Uint8Array(8);
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
buf[i] = Number(value & 0xffn);
|
||||
value >>= 8n;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
function readU64BE(bytes: Uint8Array, offset: number): bigint {
|
||||
let value = 0n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
|
|
@ -69,16 +58,6 @@ function writeU32BE(value: number): Uint8Array {
|
|||
]);
|
||||
}
|
||||
|
||||
function concatBytes(parts: Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function assertSupported(message: ParsedEncryptedMessage): void {
|
||||
if (message.version !== MTP_E2EE_VERSION) {
|
||||
throw new Error(
|
||||
|
|
@ -111,10 +90,10 @@ export function serializeEncryptedMessage(
|
|||
): Uint8Array {
|
||||
const normalized: ParsedEncryptedMessage =
|
||||
"header" in message
|
||||
? {
|
||||
? ({
|
||||
...message.header,
|
||||
ciphertext: message.aeadPayload,
|
||||
}
|
||||
} as ParsedEncryptedMessage)
|
||||
: message;
|
||||
|
||||
assertSupported(normalized);
|
||||
|
|
@ -293,29 +272,48 @@ export async function decryptPayload(args: {
|
|||
if (parsed.senderClientId !== args.session.peerClientId) {
|
||||
throw new Error("Encrypted message sender mismatch");
|
||||
}
|
||||
if (parsed.messageNumber < args.session.recvCount) {
|
||||
throw new Error("Encrypted message replay or out-of-order message number");
|
||||
|
||||
const existingSkippedMessageKeys = args.session.skippedMessageKeys ?? [];
|
||||
const cachedKeyIndex = existingSkippedMessageKeys.findIndex(
|
||||
(skipped) => skipped.messageNumber === parsed.messageNumber,
|
||||
);
|
||||
if (parsed.messageNumber < args.session.recvCount && cachedKeyIndex < 0) {
|
||||
throw new Error("Encrypted message replay message number");
|
||||
}
|
||||
|
||||
let chainKey = args.session.recvChainKey;
|
||||
let messageKey: Uint8Array | undefined;
|
||||
const gap = parsed.messageNumber - args.session.recvCount;
|
||||
if (gap > MAX_RATCHET_SKIP) {
|
||||
throw new Error(
|
||||
`Encrypted message receive gap exceeds max skip (${MAX_RATCHET_SKIP})`,
|
||||
);
|
||||
}
|
||||
let skippedMessageKeys = existingSkippedMessageKeys.slice();
|
||||
const newlyDerivedKeys: Uint8Array[] = [];
|
||||
let nextRecvCount = args.session.recvCount;
|
||||
|
||||
const steps = gap + 1;
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
const step = await MTPRatchet.stepRecv(chainKey);
|
||||
if (i === steps - 1) {
|
||||
messageKey = step.key;
|
||||
} else {
|
||||
step.key.fill(0);
|
||||
if (cachedKeyIndex >= 0) {
|
||||
// Work on a copy so an invalid ciphertext cannot consume the cached key.
|
||||
messageKey = skippedMessageKeys[cachedKeyIndex].key.slice();
|
||||
} else {
|
||||
const gap = parsed.messageNumber - args.session.recvCount;
|
||||
if (gap > MAX_RATCHET_SKIP) {
|
||||
throw new Error(
|
||||
`Encrypted message receive gap exceeds max skip (${MAX_RATCHET_SKIP})`,
|
||||
);
|
||||
}
|
||||
if (chainKey !== args.session.recvChainKey) chainKey.fill(0);
|
||||
chainKey = step.chainKey;
|
||||
|
||||
const steps = gap + 1;
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
const step = await MTPRatchet.stepRecv(chainKey);
|
||||
if (i === steps - 1) {
|
||||
messageKey = step.key;
|
||||
} else {
|
||||
skippedMessageKeys.push({
|
||||
messageNumber: args.session.recvCount + i,
|
||||
key: step.key,
|
||||
});
|
||||
newlyDerivedKeys.push(step.key);
|
||||
}
|
||||
if (chainKey !== args.session.recvChainKey) chainKey.fill(0);
|
||||
chainKey = step.chainKey;
|
||||
}
|
||||
nextRecvCount = parsed.messageNumber + 1;
|
||||
}
|
||||
if (!messageKey) {
|
||||
throw new Error("Failed to derive receive message key");
|
||||
|
|
@ -334,17 +332,31 @@ export async function decryptPayload(args: {
|
|||
let plaintext: Uint8Array;
|
||||
try {
|
||||
plaintext = cipher.decrypt(parsed.ciphertext, aad);
|
||||
} catch (error) {
|
||||
for (const key of newlyDerivedKeys) key.fill(0);
|
||||
if (chainKey !== args.session.recvChainKey) chainKey.fill(0);
|
||||
throw error;
|
||||
} finally {
|
||||
cipher.free();
|
||||
messageKey.fill(0);
|
||||
}
|
||||
|
||||
if (cachedKeyIndex >= 0) {
|
||||
const [consumed] = skippedMessageKeys.splice(cachedKeyIndex, 1);
|
||||
consumed.key.fill(0);
|
||||
}
|
||||
while (skippedMessageKeys.length > MAX_RATCHET_SKIP) {
|
||||
const evicted = skippedMessageKeys.shift();
|
||||
evicted?.key.fill(0);
|
||||
}
|
||||
|
||||
return {
|
||||
plaintext,
|
||||
session: {
|
||||
...args.session,
|
||||
recvChainKey: chainKey,
|
||||
recvCount: parsed.messageNumber + 1,
|
||||
recvCount: nextRecvCount,
|
||||
skippedMessageKeys,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
109
src/sdk/index.ts
109
src/sdk/index.ts
|
|
@ -6,6 +6,7 @@ import initWasm, {
|
|||
keyring_generate,
|
||||
} from "mtp/raw";
|
||||
import * as bindings from "mtp/raw";
|
||||
import { utf8Encode } from "./utils.js";
|
||||
import type * as RawBindings from "../raw/index";
|
||||
import type { MTPCommunicationType } from "../type-map/index";
|
||||
import type { MTPSessionStorage, MTPSessionState } from "./session";
|
||||
|
|
@ -264,6 +265,7 @@ export interface MTPClientOptions {
|
|||
serverCertificateHashes?: string[];
|
||||
maxMessageSize?: number;
|
||||
authTimeoutMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
pings?: boolean | { intervalMs?: number };
|
||||
wasm?:
|
||||
| RawBindings.InitInput
|
||||
|
|
@ -286,6 +288,7 @@ export interface MTPSendOptions {
|
|||
|
||||
export interface MTPRequestOptions extends MTPSendOptions {
|
||||
responseType?: MTPCommunicationType;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface MTPPipeWriter {
|
||||
|
|
@ -328,13 +331,21 @@ type NormalizedMTPClientOptions = Omit<MTPClientOptions, "hostPublicKey"> & {
|
|||
const DEFAULT_CREDENTIALS_KEY = "mtp:credentials";
|
||||
let wasmInitPromise: Promise<Awaited<ReturnType<typeof initWasm>>> | undefined;
|
||||
|
||||
function emit(logger, event) {
|
||||
function createMessageId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
function emit(logger: MTPClientOptions["logger"] | undefined, event: MTPLogEvent): void {
|
||||
if (typeof logger === "function") {
|
||||
logger(event);
|
||||
}
|
||||
}
|
||||
|
||||
function isErrorType(type) {
|
||||
function isErrorType(type: string): boolean {
|
||||
return (
|
||||
type === "Error" ||
|
||||
type.startsWith("Error") ||
|
||||
|
|
@ -352,7 +363,7 @@ function isErrorType(type) {
|
|||
);
|
||||
}
|
||||
|
||||
function errorMessage(frame) {
|
||||
function errorMessage(frame: Pick<ParsedFrame, "type" | "data"> | null | undefined): string {
|
||||
const data = frame?.data ?? {};
|
||||
return String(
|
||||
data.ErrorMessage ??
|
||||
|
|
@ -362,27 +373,27 @@ function errorMessage(frame) {
|
|||
);
|
||||
}
|
||||
|
||||
async function storageGet(storage, key) {
|
||||
async function storageGet(storage: MTPCredentialStorage | undefined, key: string): Promise<StorageValue> {
|
||||
return storage ? await storage.getItem(key) : null;
|
||||
}
|
||||
|
||||
async function storageSet(storage, key, value) {
|
||||
async function storageSet(storage: MTPCredentialStorage | undefined, key: string, value: string): Promise<void> {
|
||||
if (storage) {
|
||||
await storage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function storageRemove(storage, key) {
|
||||
async function storageRemove(storage: MTPCredentialStorage | undefined, key: string): Promise<void> {
|
||||
if (storage) {
|
||||
await storage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
function isBytes(value) {
|
||||
function isBytes(value: unknown): value is MTPBytesInput {
|
||||
return value instanceof Uint8Array || Array.isArray(value);
|
||||
}
|
||||
|
||||
function bytesFrom(value, name) {
|
||||
function bytesFrom(value: MTPBytesInput, name: string): Uint8Array {
|
||||
if (value instanceof Uint8Array) {
|
||||
return value;
|
||||
}
|
||||
|
|
@ -392,7 +403,7 @@ function bytesFrom(value, name) {
|
|||
throw new TypeError(`${name} must be a Uint8Array or number[]`);
|
||||
}
|
||||
|
||||
function bytesFromString(value, name) {
|
||||
function bytesFromString(value: string, name: string): Uint8Array {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new TypeError(`${name} must not be empty`);
|
||||
|
|
@ -428,7 +439,7 @@ function bytesFromString(value, name) {
|
|||
|
||||
const HEX_DIGITS = "0123456789abcdef";
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
out += HEX_DIGITS[(bytes[i] >> 4) & 0xf] + HEX_DIGITS[bytes[i] & 0xf];
|
||||
|
|
@ -436,7 +447,7 @@ function bytesToHex(bytes) {
|
|||
return out;
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes) {
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
if (typeof btoa === "function") {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
|
|
@ -450,7 +461,7 @@ export function bytesToBase64(bytes) {
|
|||
throw new TypeError("base64 encoding is not available in this environment");
|
||||
}
|
||||
|
||||
export function base64ToBytes(input) {
|
||||
export function base64ToBytes(input: string): Uint8Array {
|
||||
if (typeof atob === "function") {
|
||||
const binary = atob(input);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
|
@ -465,38 +476,7 @@ export function base64ToBytes(input) {
|
|||
throw new TypeError("base64 decoding is not available in this environment");
|
||||
}
|
||||
|
||||
function utf8Encode(text) {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(text, "utf-8"));
|
||||
}
|
||||
const bytes = new Uint8Array(text.length * 4);
|
||||
let len = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.codePointAt(i);
|
||||
if (code < 0x80) {
|
||||
bytes[len++] = code;
|
||||
} else if (code < 0x800) {
|
||||
bytes[len++] = 0xc0 | (code >> 6);
|
||||
bytes[len++] = 0x80 | (code & 0x3f);
|
||||
} else if (code < 0x10000) {
|
||||
bytes[len++] = 0xe0 | (code >> 12);
|
||||
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
|
||||
bytes[len++] = 0x80 | (code & 0x3f);
|
||||
} else {
|
||||
bytes[len++] = 0xf0 | (code >> 18);
|
||||
bytes[len++] = 0x80 | ((code >> 12) & 0x3f);
|
||||
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
|
||||
bytes[len++] = 0x80 | (code & 0x3f);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return bytes.subarray(0, len);
|
||||
}
|
||||
|
||||
function utf8Decode(bytes) {
|
||||
function utf8Decode(bytes: Uint8Array): string {
|
||||
if (typeof TextDecoder !== "undefined") {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
|
@ -537,7 +517,7 @@ function utf8Decode(bytes) {
|
|||
|
||||
const SYMMETRIC_KEY_SALT = utf8Encode("mtp-symmetric-key");
|
||||
|
||||
export function secretKeyFromString(secret) {
|
||||
export function secretKeyFromString(secret: string): Uint8Array {
|
||||
if (typeof secret !== "string" || !secret.trim()) {
|
||||
throw new TypeError("secret must be a non-empty string");
|
||||
}
|
||||
|
|
@ -571,14 +551,14 @@ export function secretKeyFromString(secret) {
|
|||
);
|
||||
}
|
||||
|
||||
function normalizeBytes(value, name) {
|
||||
function normalizeBytes(value: string | MTPBytesInput, name: string): Uint8Array {
|
||||
if (typeof value === "string") {
|
||||
return bytesFromString(value, name);
|
||||
}
|
||||
return bytesFrom(value, name);
|
||||
}
|
||||
|
||||
function normalizeCredentials(value) {
|
||||
function normalizeCredentials(value: MTPCredentials | string | null): MTPCredentials | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -590,7 +570,7 @@ function normalizeCredentials(value) {
|
|||
return value;
|
||||
}
|
||||
|
||||
function toBigInt(value) {
|
||||
function toBigInt(value: bigint | string | number | null | undefined): bigint | null {
|
||||
if (value == null || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -601,7 +581,7 @@ function generateKeyringBytes() {
|
|||
return keyring_generate();
|
||||
}
|
||||
|
||||
export function keyringToKeys(keyring) {
|
||||
export function keyringToKeys(keyring: string | MTPBytesInput): MTPKeyringKeys {
|
||||
const bytes =
|
||||
typeof keyring === "string"
|
||||
? bytesFromString(keyring, "keyring")
|
||||
|
|
@ -630,7 +610,7 @@ export function keyringToKeys(keyring) {
|
|||
};
|
||||
}
|
||||
|
||||
export function publicKeyBundleToKeys(publicKeyBundle) {
|
||||
export function publicKeyBundleToKeys(publicKeyBundle: string | MTPBytesInput): MTPPublicKeyBundleKeys {
|
||||
const bytes =
|
||||
typeof publicKeyBundle === "string"
|
||||
? bytesFromString(publicKeyBundle, "publicKeyBundle")
|
||||
|
|
@ -741,6 +721,13 @@ function validateOptions(options) {
|
|||
) {
|
||||
throw new TypeError("authTimeoutMs must be a positive safe integer");
|
||||
}
|
||||
if (
|
||||
options.requestTimeoutMs != null &&
|
||||
(!Number.isSafeInteger(options.requestTimeoutMs) ||
|
||||
options.requestTimeoutMs <= 0)
|
||||
) {
|
||||
throw new TypeError("requestTimeoutMs must be a positive safe integer");
|
||||
}
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs, message) {
|
||||
|
|
@ -1093,6 +1080,10 @@ export class MTPClient {
|
|||
data?: Record<string, unknown>,
|
||||
options: MTPRequestOptions = {},
|
||||
): Promise<ParsedFrame> {
|
||||
const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000;
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new TypeError("request timeoutMs must be a positive safe integer");
|
||||
}
|
||||
const frame = this.#buildFrame(typeOrFrame, data, options);
|
||||
try {
|
||||
const parsed = this.raw.bindings.parse_frame(frame);
|
||||
|
|
@ -1121,7 +1112,11 @@ export class MTPClient {
|
|||
direction: "send",
|
||||
});
|
||||
}
|
||||
return await this.raw.client.request(frame, options.responseType ?? null);
|
||||
return await withTimeout(
|
||||
this.raw.client.request(frame, options.responseType ?? null, timeoutMs),
|
||||
timeoutMs,
|
||||
`request timed out after ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
subscribe(
|
||||
|
|
@ -1245,7 +1240,7 @@ export class MTPClient {
|
|||
|
||||
await this.sessionManager.saveSession(newSession);
|
||||
|
||||
const messageId = String(Date.now());
|
||||
const messageId = createMessageId();
|
||||
const createdAt = Date.now();
|
||||
const senderUserId = (options as { senderUserId?: string }).senderUserId;
|
||||
const frame = this.raw.bindings.build_frame(
|
||||
|
|
@ -1346,10 +1341,7 @@ export class MTPClient {
|
|||
const sub = this.raw.client.subscribe(
|
||||
"EncryptedMessage",
|
||||
async (frame: ParsedFrame) => {
|
||||
const raw =
|
||||
frame.data?.["encryptedPayload"] ??
|
||||
frame.data?.["EncryptedPayload"] ??
|
||||
frame.data?.["encrypted_payload"];
|
||||
const raw = frame.data?.["EncryptedPayload"];
|
||||
if (!raw) return;
|
||||
|
||||
let payloadBytes: Uint8Array;
|
||||
|
|
@ -1457,10 +1449,7 @@ export class MTPClient {
|
|||
async decryptEncryptedRecord(
|
||||
frameData: Record<string, unknown>,
|
||||
): Promise<ParsedFrame> {
|
||||
const raw =
|
||||
frameData["encryptedPayload"] ??
|
||||
frameData["EncryptedPayload"] ??
|
||||
frameData["encrypted_payload"];
|
||||
const raw = frameData["EncryptedPayload"];
|
||||
if (!raw) throw new Error("EncryptedPayload is required");
|
||||
|
||||
const payloadBytes =
|
||||
|
|
|
|||
|
|
@ -1,35 +1,5 @@
|
|||
import * as bindings from "mtp/raw";
|
||||
|
||||
function utf8Encode(text: string): Uint8Array {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(text, "utf-8"));
|
||||
}
|
||||
const bytes = new Uint8Array(text.length * 4);
|
||||
let len = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.codePointAt(i) as number;
|
||||
if (code < 0x80) {
|
||||
bytes[len++] = code;
|
||||
} else if (code < 0x800) {
|
||||
bytes[len++] = 0xc0 | (code >> 6);
|
||||
bytes[len++] = 0x80 | (code & 0x3f);
|
||||
} else if (code < 0x10000) {
|
||||
bytes[len++] = 0xe0 | (code >> 12);
|
||||
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
|
||||
bytes[len++] = 0x80 | (code & 0x3f);
|
||||
} else {
|
||||
bytes[len++] = 0xf0 | (code >> 18);
|
||||
bytes[len++] = 0x80 | ((code >> 12) & 0x3f);
|
||||
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
|
||||
bytes[len++] = 0x80 | (code & 0x3f);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return bytes.subarray(0, len);
|
||||
}
|
||||
import { utf8Encode } from "./utils.js";
|
||||
|
||||
const HKDF_MESSAGE_KEY = "mtp-e2ee-v1-message-key";
|
||||
const HKDF_NEXT_CHAIN = "mtp-e2ee-v1-next-chain";
|
||||
|
|
|
|||
|
|
@ -1,35 +1,5 @@
|
|||
import * as bindings from "mtp/raw";
|
||||
|
||||
function utf8Encode(text: string): Uint8Array {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(text, "utf-8"));
|
||||
}
|
||||
const bytes = new Uint8Array(text.length * 4);
|
||||
let len = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.codePointAt(i) as number;
|
||||
if (code < 0x80) {
|
||||
bytes[len++] = code;
|
||||
} else if (code < 0x800) {
|
||||
bytes[len++] = 0xc0 | (code >> 6);
|
||||
bytes[len++] = 0x80 | (code & 0x3f);
|
||||
} else if (code < 0x10000) {
|
||||
bytes[len++] = 0xe0 | (code >> 12);
|
||||
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
|
||||
bytes[len++] = 0x80 | (code & 0x3f);
|
||||
} else {
|
||||
bytes[len++] = 0xf0 | (code >> 18);
|
||||
bytes[len++] = 0x80 | ((code >> 12) & 0x3f);
|
||||
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
|
||||
bytes[len++] = 0x80 | (code & 0x3f);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return bytes.subarray(0, len);
|
||||
}
|
||||
import { concatBytes, utf8Encode, writeU64BE } from "./utils.js";
|
||||
|
||||
export const HKDF_SALT_ROOT = "mtp-e2ee-v1-root";
|
||||
const HKDF_INITIATOR_SEND = "mtp-e2ee-v1-initiator-send";
|
||||
|
|
@ -55,10 +25,17 @@ export interface MTPSessionState {
|
|||
recvChainKey: Uint8Array;
|
||||
sendCount: number;
|
||||
recvCount: number;
|
||||
/** Derived receive keys retained for bounded out-of-order delivery. */
|
||||
skippedMessageKeys?: SkippedMessageKey[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SkippedMessageKey {
|
||||
messageNumber: number;
|
||||
key: Uint8Array;
|
||||
}
|
||||
|
||||
export interface MTPSessionStorage {
|
||||
getSession(conversationId: string): Promise<MTPSessionState | null>;
|
||||
setSession(state: MTPSessionState): Promise<void>;
|
||||
|
|
@ -68,15 +45,40 @@ export interface MTPSessionStorage {
|
|||
export class InMemorySessionStorage implements MTPSessionStorage {
|
||||
private store = new Map<string, MTPSessionState>();
|
||||
|
||||
private cloneSession(state: MTPSessionState): MTPSessionState {
|
||||
return {
|
||||
...state,
|
||||
peerPublicKey: state.peerPublicKey.slice(),
|
||||
sendChainKey: state.sendChainKey.slice(),
|
||||
recvChainKey: state.recvChainKey.slice(),
|
||||
skippedMessageKeys: (state.skippedMessageKeys ?? []).map((skipped) => ({
|
||||
messageNumber: skipped.messageNumber,
|
||||
key: skipped.key.slice(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private zeroizeSession(state: MTPSessionState): void {
|
||||
state.sendChainKey.fill(0);
|
||||
state.recvChainKey.fill(0);
|
||||
for (const skipped of state.skippedMessageKeys ?? []) skipped.key.fill(0);
|
||||
}
|
||||
|
||||
async getSession(conversationId: string): Promise<MTPSessionState | null> {
|
||||
return this.store.get(conversationId) ?? null;
|
||||
const state = this.store.get(conversationId);
|
||||
return state ? this.cloneSession(state) : null;
|
||||
}
|
||||
|
||||
async setSession(state: MTPSessionState): Promise<void> {
|
||||
this.store.set(state.conversationId, { ...state });
|
||||
const replacement = this.cloneSession(state);
|
||||
const previous = this.store.get(state.conversationId);
|
||||
if (previous) this.zeroizeSession(previous);
|
||||
this.store.set(state.conversationId, replacement);
|
||||
}
|
||||
|
||||
async deleteSession(conversationId: string): Promise<void> {
|
||||
const previous = this.store.get(conversationId);
|
||||
if (previous) this.zeroizeSession(previous);
|
||||
this.store.delete(conversationId);
|
||||
}
|
||||
}
|
||||
|
|
@ -90,27 +92,6 @@ function writeU32BE(value: number): Uint8Array {
|
|||
]);
|
||||
}
|
||||
|
||||
function writeU64BE(value: bigint): Uint8Array {
|
||||
if (value < 0n || value > 0xffff_ffff_ffff_ffffn)
|
||||
throw new Error("u64 out of range");
|
||||
const buf = new Uint8Array(8);
|
||||
for (let i = 7; i >= 0; i -= 1) {
|
||||
buf[i] = Number(value & 0xffn);
|
||||
value >>= 8n;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
function concatBytes(parts: Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function transcriptField(label: string, value: Uint8Array): Uint8Array {
|
||||
const labelBytes = utf8Encode(label);
|
||||
return concatBytes([
|
||||
|
|
@ -240,6 +221,7 @@ export class MTPSessionManager {
|
|||
recvChainKey: args.role === "initiator" ? initiatorRecv : initiatorSend,
|
||||
sendCount: 0,
|
||||
recvCount: 0,
|
||||
skippedMessageKeys: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
|
|
|||
28
src/sdk/utils.ts
Normal file
28
src/sdk/utils.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export function utf8Encode(text: string): Uint8Array {
|
||||
if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(text);
|
||||
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(text, "utf-8"));
|
||||
const bytes = new Uint8Array(text.length * 4);
|
||||
let len = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.codePointAt(i) as number;
|
||||
if (code < 0x80) bytes[len++] = code;
|
||||
else if (code < 0x800) { bytes[len++] = 0xc0 | (code >> 6); bytes[len++] = 0x80 | (code & 0x3f); }
|
||||
else if (code < 0x10000) { bytes[len++] = 0xe0 | (code >> 12); bytes[len++] = 0x80 | ((code >> 6) & 0x3f); bytes[len++] = 0x80 | (code & 0x3f); }
|
||||
else { bytes[len++] = 0xf0 | (code >> 18); bytes[len++] = 0x80 | ((code >> 12) & 0x3f); bytes[len++] = 0x80 | ((code >> 6) & 0x3f); bytes[len++] = 0x80 | (code & 0x3f); i += 1; }
|
||||
}
|
||||
return bytes.subarray(0, len);
|
||||
}
|
||||
|
||||
export function writeU64BE(value: bigint): Uint8Array {
|
||||
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) throw new Error("u64 value out of range");
|
||||
const out = new Uint8Array(8);
|
||||
for (let i = 7; i >= 0; i -= 1) { out[i] = Number(value & 0xffn); value >>= 8n; }
|
||||
return out;
|
||||
}
|
||||
|
||||
export function concatBytes(parts: Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) { out.set(part, offset); offset += part.length; }
|
||||
return out;
|
||||
}
|
||||
16
src/type-map/reserved.ts
Normal file
16
src/type-map/reserved.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export const RESERVED_COMMUNICATION_TYPES = [
|
||||
"Identification", "IdentificationResponse", "Register", "RegisterResponse",
|
||||
"Challenge", "ChallengeResponse", "Ping", "Pong", "Disconnect", "Redirect",
|
||||
"Shutdown", "Error", "ErrorParsing", "ErrorBadVersion", "BadRequest",
|
||||
"Unauthorized", "Forbidden", "NotFound", "TooManyRequests", "InternalServerError",
|
||||
"BadGateway", "ServiceUnavailable", "GatewayTimeout", "PipeRequest", "PipeResponse",
|
||||
"PipeAbort",
|
||||
] as const;
|
||||
|
||||
export const RESERVED_DATA_TYPES = [
|
||||
"Version", "Id", "ClientNonce", "ServerNonce", "PublicKeys", "Signature",
|
||||
"PqSignature", "Description", "Connected", "Timestamp", "Error", "ErrorParsing",
|
||||
"ErrorMessage", "Accepted", "RequirePq",
|
||||
] as const;
|
||||
|
||||
export const FIRST_USER_TYPE_ID = 32;
|
||||
|
|
@ -4,6 +4,12 @@ import { spawn } from "node:child_process";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import YAML from "yaml";
|
||||
import {
|
||||
FIRST_USER_TYPE_ID,
|
||||
RESERVED_COMMUNICATION_TYPES,
|
||||
RESERVED_DATA_TYPES,
|
||||
} from "../type-map/reserved.js";
|
||||
|
||||
export interface MTPVitePluginOptions {
|
||||
typeMaps: string;
|
||||
|
|
@ -17,6 +23,7 @@ export interface VitePlugin {
|
|||
config?: (...args: any[]) => unknown;
|
||||
buildStart?: (...args: any[]) => unknown;
|
||||
configureServer?: (...args: any[]) => unknown;
|
||||
addWatchFile?: (file: string) => void;
|
||||
}
|
||||
|
||||
const packageRoot = process.env.MTP_PACKAGE_ROOT
|
||||
|
|
@ -26,51 +33,11 @@ const rawEntryName = "mtp_wasm.js";
|
|||
const wasmEntryName = "mtp_wasm_bg.wasm";
|
||||
const typeMapEntryName = "mtp_type_map.js";
|
||||
|
||||
const reservedCommunicationTypes = [
|
||||
"Identification",
|
||||
"IdentificationResponse",
|
||||
"Register",
|
||||
"RegisterResponse",
|
||||
"Challenge",
|
||||
"ChallengeResponse",
|
||||
"Ping",
|
||||
"Pong",
|
||||
"Disconnect",
|
||||
"Redirect",
|
||||
"Shutdown",
|
||||
"Error",
|
||||
"ErrorParsing",
|
||||
"ErrorBadVersion",
|
||||
"BadRequest",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"NotFound",
|
||||
"TooManyRequests",
|
||||
"InternalServerError",
|
||||
"BadGateway",
|
||||
"ServiceUnavailable",
|
||||
"GatewayTimeout",
|
||||
];
|
||||
|
||||
const reservedDataTypes = [
|
||||
"Version",
|
||||
"Id",
|
||||
"ClientNonce",
|
||||
"ServerNonce",
|
||||
"PublicKeys",
|
||||
"Signature",
|
||||
"PqSignature",
|
||||
"Description",
|
||||
"Connected",
|
||||
"Timestamp",
|
||||
"Error",
|
||||
"ErrorParsing",
|
||||
"ErrorMessage",
|
||||
];
|
||||
|
||||
function normalizeOptions(options) {
|
||||
if (!options?.typeMaps) {
|
||||
throw new Error("mtp/vite requires a typeMaps option, for example mtp({ typeMaps: './type-maps.yaml' })");
|
||||
throw new Error(
|
||||
"mtp/vite requires a typeMaps option, for example mtp({ typeMaps: './type-maps.yaml' })",
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
|
|
@ -136,62 +103,69 @@ async function hashPackageInputs() {
|
|||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function quoteList(values) {
|
||||
function quoteList(values: string[]): string {
|
||||
return values.length === 0
|
||||
? "never"
|
||||
: values.map((value) => JSON.stringify(value)).join(" | ");
|
||||
}
|
||||
|
||||
function parseTypeMapYaml(source, filePath) {
|
||||
const communicationTypes = new Set(reservedCommunicationTypes);
|
||||
const dataTypes = new Set(reservedDataTypes);
|
||||
let section = null;
|
||||
let sectionIndent = -1;
|
||||
|
||||
for (const [index, originalLine] of source.split(/\r?\n/).entries()) {
|
||||
const withoutComment = originalLine.replace(/\s+#.*$/, "");
|
||||
if (!withoutComment.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (/^\t/.test(withoutComment)) {
|
||||
throw new Error(`${filePath}:${index + 1}: tabs are not supported in type-maps.yaml indentation`);
|
||||
}
|
||||
|
||||
const indent = withoutComment.match(/^ */)?.[0].length ?? 0;
|
||||
const trimmed = withoutComment.trim();
|
||||
const sectionMatch = trimmed.match(/^(CommunicationTypes|DataTypes):\s*$/);
|
||||
if (sectionMatch) {
|
||||
section = sectionMatch[1];
|
||||
sectionIndent = indent;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section && indent <= sectionIndent) {
|
||||
section = null;
|
||||
}
|
||||
if (!section) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*\d+\s*$/);
|
||||
if (!entryMatch) {
|
||||
throw new Error(`${filePath}:${index + 1}: expected '${section}' entries as 'Name: numeric_id'`);
|
||||
}
|
||||
|
||||
if (section === "CommunicationTypes") {
|
||||
communicationTypes.add(entryMatch[1]);
|
||||
} else {
|
||||
dataTypes.add(entryMatch[1]);
|
||||
function parseTypeMapYaml(source: string, filePath: string) {
|
||||
const document = YAML.parseDocument(source, { prettyErrors: false });
|
||||
if (document.errors.length) {
|
||||
const error = document.errors[0];
|
||||
const line =
|
||||
error.pos?.[0] === undefined
|
||||
? 1
|
||||
: source.slice(0, error.pos[0]).split("\n").length;
|
||||
throw new Error(`${filePath}:${line}: ${error.message}`);
|
||||
}
|
||||
const root = document.toJS() as {
|
||||
type_maps?: Record<
|
||||
string,
|
||||
{
|
||||
CommunicationTypes?: Record<string, unknown>;
|
||||
DataTypes?: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
const communicationTypes = new Set<string>(RESERVED_COMMUNICATION_TYPES);
|
||||
const dataTypes = new Set<string>(RESERVED_DATA_TYPES);
|
||||
for (const [version, map] of Object.entries(root.type_maps ?? {})) {
|
||||
if (!/^\d+\.\d+$/.test(version))
|
||||
throw new Error(`${filePath}: unparseable type-map version '${version}'`);
|
||||
for (const [section, target] of [
|
||||
["CommunicationTypes", communicationTypes],
|
||||
["DataTypes", dataTypes],
|
||||
] as const) {
|
||||
const ids = new Map<number, string>();
|
||||
for (const [name, value] of Object.entries(
|
||||
map[section as "CommunicationTypes" | "DataTypes"] ?? {},
|
||||
)) {
|
||||
if (!Number.isInteger(value) || (value as number) < FIRST_USER_TYPE_ID)
|
||||
throw new Error(
|
||||
`${filePath}: ${version}.${section}.${name} must use an integer id >= ${FIRST_USER_TYPE_ID}`,
|
||||
);
|
||||
const id = value as number;
|
||||
const previous = ids.get(id);
|
||||
if (previous && previous !== name)
|
||||
throw new Error(
|
||||
`${filePath}: duplicate type id ${id} in ${section} (${previous} and ${name})`,
|
||||
);
|
||||
ids.set(id, name);
|
||||
target.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
communicationTypes: [...communicationTypes].sort(),
|
||||
dataTypes: [...dataTypes].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function generateTypeMapModule(metadata) {
|
||||
function generateTypeMapModule(metadata: {
|
||||
communicationTypes: string[];
|
||||
dataTypes: string[];
|
||||
}) {
|
||||
const js = `export const communicationTypes = ${JSON.stringify(metadata.communicationTypes, null, 2)};\nexport const dataTypes = ${JSON.stringify(metadata.dataTypes, null, 2)};\n`;
|
||||
const dts = `export type MTPCommunicationType = ${quoteList(metadata.communicationTypes)};\nexport type MTPDataType = ${quoteList(metadata.dataTypes)};\nexport declare const communicationTypes: readonly MTPCommunicationType[];\nexport declare const dataTypes: readonly MTPDataType[];\n`;
|
||||
return { js, dts };
|
||||
|
|
@ -199,7 +173,9 @@ function generateTypeMapModule(metadata) {
|
|||
|
||||
async function writeTypeMapModule(outDir, typeMapsPath) {
|
||||
const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => {
|
||||
throw new Error(`Failed to read type map '${typeMapsPath}': ${error.message}`);
|
||||
throw new Error(
|
||||
`Failed to read type map '${typeMapsPath}': ${error.message}`,
|
||||
);
|
||||
});
|
||||
const metadata = parseTypeMapYaml(source, typeMapsPath);
|
||||
const module = generateTypeMapModule(metadata);
|
||||
|
|
@ -222,7 +198,7 @@ async function copyWasmBuildInputs(buildRoot) {
|
|||
|
||||
for (const input of inputs) {
|
||||
const source = path.join(packageRoot, input);
|
||||
if (!await pathExists(source)) {
|
||||
if (!(await pathExists(source))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +231,9 @@ async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) {
|
|||
env: {
|
||||
...process.env,
|
||||
MTP_TYPE_MAPS: typeMapsPath,
|
||||
RUSTFLAGS: [process.env.RUSTFLAGS, "--cfg web_sys_unstable_apis"].filter(Boolean).join(" "),
|
||||
RUSTFLAGS: [process.env.RUSTFLAGS, "--cfg web_sys_unstable_apis"]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
|
@ -270,7 +248,11 @@ async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) {
|
|||
});
|
||||
child.on("error", (error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
reject(new Error("Failed to run wasm-pack. Install wasm-pack or enter the project Nix dev shell, then retry."));
|
||||
reject(
|
||||
new Error(
|
||||
"Failed to run wasm-pack. Install wasm-pack or enter the project Nix dev shell, then retry.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
|
|
@ -279,7 +261,11 @@ async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) {
|
|||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`wasm-pack failed with exit code ${code}.\n${stdout}${stderr}`.trim()));
|
||||
reject(
|
||||
new Error(
|
||||
`wasm-pack failed with exit code ${code}.\n${stdout}${stderr}`.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -294,37 +280,60 @@ async function buildIfNeeded(state, force = false) {
|
|||
}
|
||||
|
||||
state.buildPromise = (async () => {
|
||||
const typeMapSource = await writeTypeMapModule(state.outDir, state.typeMapsPath);
|
||||
const typeMapSource = await writeTypeMapModule(
|
||||
state.outDir,
|
||||
state.typeMapsPath,
|
||||
);
|
||||
const packageInputs = await hashPackageInputs();
|
||||
const fingerprint = crypto
|
||||
.createHash("sha256")
|
||||
.update(JSON.stringify({
|
||||
packageRoot,
|
||||
packageInputs,
|
||||
typeMapsPath: state.typeMapsPath,
|
||||
typeMapSource,
|
||||
release: state.release,
|
||||
wasmPackArgs: state.wasmPackArgs,
|
||||
}))
|
||||
.update(
|
||||
JSON.stringify({
|
||||
packageRoot,
|
||||
packageInputs,
|
||||
typeMapsPath: state.typeMapsPath,
|
||||
typeMapSource,
|
||||
release: state.release,
|
||||
wasmPackArgs: state.wasmPackArgs,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
const stampPath = path.join(state.outDir, ".mtp-build.json");
|
||||
const rawEntryPath = path.join(state.outDir, rawEntryName);
|
||||
const wasmPath = path.join(state.outDir, wasmEntryName);
|
||||
let previousFingerprint = null;
|
||||
try {
|
||||
previousFingerprint = JSON.parse(await fs.readFile(stampPath, "utf8")).fingerprint;
|
||||
previousFingerprint = JSON.parse(
|
||||
await fs.readFile(stampPath, "utf8"),
|
||||
).fingerprint;
|
||||
} catch {
|
||||
previousFingerprint = null;
|
||||
}
|
||||
|
||||
if (!force && previousFingerprint === fingerprint && await pathExists(rawEntryPath) && await pathExists(wasmPath)) {
|
||||
if (
|
||||
!force &&
|
||||
previousFingerprint === fingerprint &&
|
||||
(await pathExists(rawEntryPath)) &&
|
||||
(await pathExists(wasmPath))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info("\x1b[1m\x1b[35mmtp\x1b[0m compiling wasm... (this could take a minute)");
|
||||
console.info(
|
||||
"\x1b[1m\x1b[35mmtp\x1b[0m compiling wasm... (this could take a minute)",
|
||||
);
|
||||
await runWasmPack(state);
|
||||
console.log("\x1b[1m\x1b[35mmtp\x1b[0m \x1b[32mcompilation finished.\x1b[0m");
|
||||
await fs.writeFile(stampPath, JSON.stringify({ fingerprint, builtAt: new Date().toISOString() }, null, 2));
|
||||
console.log(
|
||||
"\x1b[1m\x1b[35mmtp\x1b[0m \x1b[32mcompilation finished.\x1b[0m",
|
||||
);
|
||||
await fs.writeFile(
|
||||
stampPath,
|
||||
JSON.stringify(
|
||||
{ fingerprint, builtAt: new Date().toISOString() },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
})().finally(() => {
|
||||
state.buildPromise = null;
|
||||
});
|
||||
|
|
@ -334,7 +343,7 @@ async function buildIfNeeded(state, force = false) {
|
|||
|
||||
export function mtp(options: MTPVitePluginOptions): VitePlugin {
|
||||
const normalized = normalizeOptions(options);
|
||||
const state = {
|
||||
const state: any = {
|
||||
outDir: null,
|
||||
typeMapsPath: null,
|
||||
release: true,
|
||||
|
|
@ -347,11 +356,16 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
|
|||
async config(config, env) {
|
||||
const root = path.resolve(config.root ?? process.cwd());
|
||||
state.typeMapsPath = path.resolve(root, normalized.typeMaps);
|
||||
state.outDir = path.resolve(root, normalized.outDir ?? path.join("node_modules", ".vite", "mtp"));
|
||||
state.outDir = path.resolve(
|
||||
root,
|
||||
normalized.outDir ?? path.join("node_modules", ".vite", "mtp"),
|
||||
);
|
||||
state.release = normalized.release ?? env.command === "build";
|
||||
|
||||
if (!await pathExists(state.typeMapsPath)) {
|
||||
throw new Error(`mtp/vite could not find typeMaps file: ${state.typeMapsPath}`);
|
||||
if (!(await pathExists(state.typeMapsPath))) {
|
||||
throw new Error(
|
||||
`mtp/vite could not find typeMaps file: ${state.typeMapsPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
await buildIfNeeded(state);
|
||||
|
|
@ -367,21 +381,27 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
|
|||
};
|
||||
},
|
||||
buildStart() {
|
||||
this.addWatchFile(state.typeMapsPath);
|
||||
(this as any).addWatchFile(state.typeMapsPath);
|
||||
},
|
||||
async configureServer(server) {
|
||||
const wasmPath = path.join(state.outDir, wasmEntryName);
|
||||
const wasmUrl = devServerPath(server.config.root, wasmPath);
|
||||
if (wasmUrl) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (!req.url || new URL(req.url, "http://localhost").pathname !== wasmUrl) {
|
||||
if (
|
||||
!req.url ||
|
||||
new URL(req.url, "http://localhost").pathname !== wasmUrl
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
res.setHeader("Content-Type", "application/wasm");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"no-cache, no-store, must-revalidate",
|
||||
);
|
||||
res.end(await fs.readFile(wasmPath));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
|
@ -408,7 +428,9 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
|
|||
const scheduleRebuild = (changedPath: string) => {
|
||||
const resolved = path.resolve(changedPath);
|
||||
const isTypeMap = resolved === state.typeMapsPath;
|
||||
const isSource = sourceWatchDirs.some((dir) => resolved.startsWith(`${dir}${path.sep}`));
|
||||
const isSource = sourceWatchDirs.some((dir) =>
|
||||
resolved.startsWith(`${dir}${path.sep}`),
|
||||
);
|
||||
if (!isTypeMap && !isSource) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -423,9 +445,13 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
|
|||
try {
|
||||
await buildIfNeeded(state, true);
|
||||
server.moduleGraph.invalidateAll();
|
||||
server.ws.send({ type: "full-reload" });
|
||||
if (server.ws) {
|
||||
server.ws.send({ type: "full-reload" });
|
||||
}
|
||||
} catch (error) {
|
||||
server.config.logger.error(error instanceof Error ? error.message : String(error));
|
||||
server.config.logger.error(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, 200);
|
||||
|
|
|
|||
Loading…
Reference in a new issue