(feat): redesign WASM module, add TypeScript SDK, migrate to pnpm
Some checks failed
CI / rustfmt (push) Successful in 17s
CI / wasm build (push) Successful in 1m16s
CI / clippy (push) Successful in 1m28s
CI / test (push) Successful in 1m48s
CI / example (push) Successful in 1m31s
CI / duplicate code (push) Failing after 33s
CI / web client (push) Failing after 34s
CI / cargo-machete (push) Successful in 1m18s
CI / cargo-deny (push) Failing after 3m2s
Some checks failed
CI / rustfmt (push) Successful in 17s
CI / wasm build (push) Successful in 1m16s
CI / clippy (push) Successful in 1m28s
CI / test (push) Successful in 1m48s
CI / example (push) Successful in 1m31s
CI / duplicate code (push) Failing after 33s
CI / web client (push) Failing after 34s
CI / cargo-machete (push) Successful in 1m18s
CI / cargo-deny (push) Failing after 3m2s
This commit is contained in:
parent
89a20044a5
commit
5caa1c9d5f
49 changed files with 3717 additions and 1501 deletions
2
src/raw/index.ts
Normal file
2
src/raw/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default } from "../../wasm/pkg/mtp_wasm.js";
|
||||
export * from "../../wasm/pkg/mtp_wasm.js";
|
||||
514
src/sdk/index.ts
Normal file
514
src/sdk/index.ts
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import initWasm, {
|
||||
ConnectionConfig,
|
||||
ConnectionState,
|
||||
WasmClient,
|
||||
ed25519_generate,
|
||||
keyring_from_ed25519,
|
||||
} from "mtp/raw";
|
||||
import * as bindings from "mtp/raw";
|
||||
import type * as RawBindings from "../raw/index";
|
||||
import type { MTPCommunicationType } from "../type-map/index";
|
||||
|
||||
export type StorageValue = string | null;
|
||||
|
||||
export interface MTPCredentialStorage {
|
||||
getItem(key: string): StorageValue | Promise<StorageValue>;
|
||||
setItem(key: string, value: string): void | Promise<void>;
|
||||
removeItem(key: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
export type MTPStorage = MTPCredentialStorage;
|
||||
|
||||
export type MTPLogEvent =
|
||||
| { hint: "info" | "warning"; type: string; data: unknown }
|
||||
| { hint: "error"; type: string | "error"; error: string };
|
||||
|
||||
export type ParsedFrame = RawBindings.ParsedFrame;
|
||||
|
||||
export interface MTPCredentials {
|
||||
clientId: bigint | string | number | null;
|
||||
keyring: Uint8Array | number[];
|
||||
/** @deprecated Use keyring. Kept as a migration alias for existing callers. */
|
||||
keyringBytes?: Uint8Array | number[];
|
||||
hostPublicKey?: Uint8Array | number[];
|
||||
}
|
||||
|
||||
export interface MTPClientOptions {
|
||||
url: string;
|
||||
hostPublicKey?: Uint8Array | string;
|
||||
credentials?: MTPCredentials | string | null;
|
||||
credentialsStorageKey?: string;
|
||||
storage?: MTPCredentialStorage;
|
||||
serverCertificateHashes?: string[];
|
||||
pings?: boolean | { intervalMs?: number };
|
||||
wasm?: RawBindings.InitInput | Promise<RawBindings.InitInput> | { module_or_path: RawBindings.InitInput | Promise<RawBindings.InitInput> };
|
||||
logger?: (event: MTPLogEvent) => void;
|
||||
}
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export interface MTPSendOptions {
|
||||
id?: number;
|
||||
sender?: bigint | number;
|
||||
receiver?: bigint | number;
|
||||
}
|
||||
|
||||
export interface MTPRequestOptions extends MTPSendOptions {
|
||||
responseType?: MTPCommunicationType;
|
||||
}
|
||||
|
||||
type InternalCredentials = Omit<MTPCredentials, "clientId" | "keyring" | "hostPublicKey"> & {
|
||||
clientId: bigint | null;
|
||||
keyringBytes: Uint8Array;
|
||||
hostPublicKey?: Uint8Array;
|
||||
};
|
||||
|
||||
type NormalizedMTPClientOptions = Omit<MTPClientOptions, "hostPublicKey"> & {
|
||||
hostPublicKey?: Uint8Array;
|
||||
};
|
||||
|
||||
const DEFAULT_CREDENTIALS_KEY = "mtp:credentials";
|
||||
|
||||
function emit(logger, event) {
|
||||
if (typeof logger === "function") {
|
||||
logger(event);
|
||||
}
|
||||
}
|
||||
|
||||
function isErrorType(type) {
|
||||
return type === "Error" || type.startsWith("Error") || [
|
||||
"BadRequest",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"NotFound",
|
||||
"TooManyRequests",
|
||||
"InternalServerError",
|
||||
"BadGateway",
|
||||
"ServiceUnavailable",
|
||||
"GatewayTimeout",
|
||||
].includes(type);
|
||||
}
|
||||
|
||||
function errorMessage(frame) {
|
||||
const data = frame?.data ?? {};
|
||||
return String(data.ErrorMessage ?? data.Error ?? data.Description ?? `Received ${frame?.type ?? "error"} frame`);
|
||||
}
|
||||
|
||||
async function storageGet(storage, key) {
|
||||
return storage ? await storage.getItem(key) : null;
|
||||
}
|
||||
|
||||
async function storageSet(storage, key, value) {
|
||||
if (storage) {
|
||||
await storage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function storageRemove(storage, key) {
|
||||
if (storage) {
|
||||
await storage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
function isBytes(value) {
|
||||
return value instanceof Uint8Array || Array.isArray(value);
|
||||
}
|
||||
|
||||
function bytesFrom(value, name) {
|
||||
if (value instanceof Uint8Array) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return new Uint8Array(value);
|
||||
}
|
||||
throw new TypeError(`${name} must be a Uint8Array or number[]`);
|
||||
}
|
||||
|
||||
function bytesFromString(value, name) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new TypeError(`${name} must not be empty`);
|
||||
}
|
||||
|
||||
const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, "");
|
||||
if (/^[0-9a-fA-F]+$/.test(hex)) {
|
||||
if (hex.length % 2 !== 0) {
|
||||
throw new TypeError(`${name} hex string has an odd length`);
|
||||
}
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (typeof atob === "function") {
|
||||
const binary = atob(trimmed);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(trimmed, "base64"));
|
||||
}
|
||||
|
||||
throw new TypeError(`${name} must be bytes, hex, or base64`);
|
||||
}
|
||||
|
||||
function normalizeBytes(value, name) {
|
||||
if (typeof value === "string") {
|
||||
return bytesFromString(value, name);
|
||||
}
|
||||
return bytesFrom(value, name);
|
||||
}
|
||||
|
||||
function normalizeCredentials(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function toBigInt(value) {
|
||||
if (value == null || value === "") {
|
||||
return null;
|
||||
}
|
||||
return typeof value === "bigint" ? value : BigInt(value);
|
||||
}
|
||||
|
||||
function generateKeyringBytes() {
|
||||
const generated = ed25519_generate();
|
||||
try {
|
||||
return keyring_from_ed25519(generated.secretKey, generated.publicKey);
|
||||
} finally {
|
||||
generated.signer?.free?.();
|
||||
}
|
||||
}
|
||||
|
||||
function serializeCredentials(credentials) {
|
||||
return JSON.stringify({
|
||||
clientId: credentials.clientId?.toString() ?? null,
|
||||
keyring: Array.from(credentials.keyringBytes ?? []),
|
||||
hostPublicKey: credentials.hostPublicKey ? Array.from(credentials.hostPublicKey) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function deserializeCredentials(credentials) {
|
||||
const normalized = normalizeCredentials(credentials);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyring = normalized.keyring ?? normalized.keyringBytes;
|
||||
if (!isBytes(keyring)) {
|
||||
throw new TypeError("credentials.keyring must be a Uint8Array or number[]");
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: toBigInt(normalized.clientId),
|
||||
keyringBytes: bytesFrom(keyring, "credentials.keyring"),
|
||||
hostPublicKey: normalized.hostPublicKey == null
|
||||
? undefined
|
||||
: normalizeBytes(normalized.hostPublicKey, "credentials.hostPublicKey"),
|
||||
};
|
||||
}
|
||||
|
||||
function publicCredentials(credentials) {
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clientId: credentials.clientId,
|
||||
keyring: credentials.keyringBytes,
|
||||
keyringBytes: credentials.keyringBytes,
|
||||
hostPublicKey: credentials.hostPublicKey,
|
||||
};
|
||||
}
|
||||
|
||||
function validateOptions(options) {
|
||||
if (!options || typeof options !== "object") {
|
||||
throw new TypeError("MTPClient.create requires an options object");
|
||||
}
|
||||
if (typeof options.url !== "string" || !options.url.trim()) {
|
||||
throw new TypeError("MTPClient.create requires a non-empty url");
|
||||
}
|
||||
if (options.storage) {
|
||||
for (const method of ["getItem", "setItem", "removeItem"]) {
|
||||
if (typeof options.storage[method] !== "function") {
|
||||
throw new TypeError(`storage.${method} must be a function`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MTPClient {
|
||||
#credentials: InternalCredentials | null;
|
||||
#options: NormalizedMTPClientOptions;
|
||||
readonly raw: {
|
||||
client: RawBindings.WasmClient;
|
||||
bindings: typeof RawBindings;
|
||||
};
|
||||
|
||||
private constructor(options: NormalizedMTPClientOptions, client: RawBindings.WasmClient) {
|
||||
this.#options = options;
|
||||
this.#credentials = deserializeCredentials(options.credentials);
|
||||
this.raw = { client, bindings };
|
||||
}
|
||||
|
||||
static async create(options: MTPClientOptions = {} as MTPClientOptions): Promise<MTPClient> {
|
||||
validateOptions(options);
|
||||
await initWasm(options.wasm);
|
||||
|
||||
const normalizedOptions = {
|
||||
...options,
|
||||
hostPublicKey: options.hostPublicKey == null
|
||||
? undefined
|
||||
: normalizeBytes(options.hostPublicKey, "hostPublicKey"),
|
||||
};
|
||||
|
||||
let sdk: MTPClient | undefined;
|
||||
const client = new WasmClient(
|
||||
(state) => emit(normalizedOptions.logger, {
|
||||
hint: "info",
|
||||
type: "state",
|
||||
data: ConnectionState[state] ?? state,
|
||||
}),
|
||||
(frame) => {
|
||||
if (sdk) {
|
||||
sdk.#handleFrame(frame);
|
||||
}
|
||||
},
|
||||
(error) => emit(normalizedOptions.logger, {
|
||||
hint: "error",
|
||||
type: "error",
|
||||
error: String(error),
|
||||
}),
|
||||
);
|
||||
|
||||
sdk = new MTPClient(normalizedOptions, client);
|
||||
await sdk.#loadStoredCredentials();
|
||||
if (!sdk.#credentials) {
|
||||
sdk.#credentials = {
|
||||
clientId: null,
|
||||
keyringBytes: generateKeyringBytes(),
|
||||
hostPublicKey: normalizedOptions.hostPublicKey,
|
||||
};
|
||||
} else if (!sdk.#credentials.hostPublicKey && normalizedOptions.hostPublicKey) {
|
||||
sdk.#credentials = { ...sdk.#credentials, hostPublicKey: normalizedOptions.hostPublicKey };
|
||||
} else if (!normalizedOptions.hostPublicKey && sdk.#credentials.hostPublicKey) {
|
||||
sdk.#options = { ...sdk.#options, hostPublicKey: sdk.#credentials.hostPublicKey };
|
||||
}
|
||||
return sdk;
|
||||
}
|
||||
|
||||
static isSupported(): boolean {
|
||||
return WasmClient.is_supported();
|
||||
}
|
||||
|
||||
get credentials(): MTPCredentials | null {
|
||||
return publicCredentials(this.#credentials);
|
||||
}
|
||||
|
||||
async #loadStoredCredentials() {
|
||||
if (this.#credentials || !this.#options.storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = await storageGet(
|
||||
this.#options.storage,
|
||||
this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY,
|
||||
);
|
||||
this.#credentials = deserializeCredentials(stored);
|
||||
}
|
||||
|
||||
#connectionConfig() {
|
||||
const config = new ConnectionConfig(this.#options.url);
|
||||
if (this.#options.serverCertificateHashes) {
|
||||
config.server_certificate_hashes = this.#options.serverCertificateHashes;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.#credentials?.clientId != null && this.#options.hostPublicKey) {
|
||||
await this.#connectAuthenticated();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.#connectionConfig();
|
||||
try {
|
||||
await this.raw.client.connect(config);
|
||||
this.#startPings(0n);
|
||||
} finally {
|
||||
config.free();
|
||||
}
|
||||
}
|
||||
|
||||
async #connectAuthenticated() {
|
||||
if (!this.#options.hostPublicKey) {
|
||||
throw new Error("MTPClient.connect requires hostPublicKey for authenticated connections");
|
||||
}
|
||||
if (!this.#credentials?.keyringBytes?.length || this.#credentials.clientId == null) {
|
||||
throw new Error("MTPClient.connect requires credentials with clientId and keyring");
|
||||
}
|
||||
|
||||
const config = this.#connectionConfig();
|
||||
try {
|
||||
const clientId = await this.raw.client.auth_connect(
|
||||
config,
|
||||
this.#options.hostPublicKey,
|
||||
this.#credentials.keyringBytes,
|
||||
this.#credentials.clientId,
|
||||
);
|
||||
this.#credentials = { ...this.#credentials, clientId };
|
||||
await this.#persistCredentials();
|
||||
this.#startPings(clientId);
|
||||
return clientId;
|
||||
} finally {
|
||||
config.free();
|
||||
}
|
||||
}
|
||||
|
||||
async register(): Promise<bigint> {
|
||||
if (!this.#options.hostPublicKey) {
|
||||
throw new Error("MTPClient.register requires hostPublicKey");
|
||||
}
|
||||
if (!this.#credentials?.keyringBytes?.length) {
|
||||
this.#credentials = {
|
||||
clientId: null,
|
||||
keyringBytes: generateKeyringBytes(),
|
||||
hostPublicKey: this.#options.hostPublicKey,
|
||||
};
|
||||
}
|
||||
|
||||
const config = this.#connectionConfig();
|
||||
try {
|
||||
const clientId = await this.raw.client.auth_register(
|
||||
config,
|
||||
this.#options.hostPublicKey,
|
||||
this.#credentials.keyringBytes,
|
||||
);
|
||||
this.#credentials = { ...this.#credentials, clientId };
|
||||
await this.#persistCredentials();
|
||||
this.#startPings(clientId);
|
||||
return clientId;
|
||||
} finally {
|
||||
config.free();
|
||||
}
|
||||
}
|
||||
|
||||
async connectOrRegister(): Promise<bigint> {
|
||||
return this.#credentials?.clientId == null
|
||||
? await this.register()
|
||||
: await this.#connectAuthenticated();
|
||||
}
|
||||
|
||||
async #persistCredentials() {
|
||||
await storageSet(
|
||||
this.#options.storage,
|
||||
this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY,
|
||||
serializeCredentials(this.#credentials),
|
||||
);
|
||||
}
|
||||
|
||||
async clearCredentials(): Promise<void> {
|
||||
this.#credentials = null;
|
||||
await storageRemove(
|
||||
this.#options.storage,
|
||||
this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
#startPings(clientId) {
|
||||
const pings = this.#options.pings;
|
||||
if (!pings) {
|
||||
this.raw.client.stop_protocol_pings();
|
||||
return;
|
||||
}
|
||||
const intervalMs = typeof pings === "object" ? pings.intervalMs ?? 30_000 : 30_000;
|
||||
this.raw.client.start_protocol_pings(intervalMs, clientId);
|
||||
}
|
||||
|
||||
#buildFrame(typeOrFrame, data, options) {
|
||||
if (typeOrFrame instanceof Uint8Array) {
|
||||
return typeOrFrame;
|
||||
}
|
||||
if (typeof typeOrFrame !== "string" || !typeOrFrame) {
|
||||
throw new TypeError("message type must be a non-empty string or Uint8Array frame");
|
||||
}
|
||||
if (data == null || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw new TypeError("message data must be an object");
|
||||
}
|
||||
return this.raw.bindings.build_frame(typeOrFrame, data, options ?? {});
|
||||
}
|
||||
|
||||
async send(message: Uint8Array): Promise<void>;
|
||||
async send(type: MTPCommunicationType, data: Record<string, unknown>, options?: MTPSendOptions): Promise<void>;
|
||||
async send(typeOrFrame: Uint8Array | MTPCommunicationType, data?: Record<string, unknown>, options?: MTPSendOptions): Promise<void> {
|
||||
const message = this.#buildFrame(typeOrFrame, data, options);
|
||||
|
||||
try {
|
||||
const frame = this.raw.bindings.parse_frame(message);
|
||||
emit(this.#options.logger, isErrorType(frame.type)
|
||||
? { hint: "error", type: frame.type, error: errorMessage(frame) }
|
||||
: { hint: "info", type: frame.type, data: frame.data });
|
||||
} catch (error) {
|
||||
emit(this.#options.logger, {
|
||||
hint: "error",
|
||||
type: "error",
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
|
||||
await this.raw.client.send(message);
|
||||
}
|
||||
|
||||
async request(message: Uint8Array, data?: never, options?: MTPRequestOptions): Promise<ParsedFrame>;
|
||||
async request(type: MTPCommunicationType, data: Record<string, unknown>, options?: MTPRequestOptions): Promise<ParsedFrame>;
|
||||
async request(typeOrFrame: Uint8Array | MTPCommunicationType, data?: Record<string, unknown>, options: MTPRequestOptions = {}): Promise<ParsedFrame> {
|
||||
const frame = this.#buildFrame(typeOrFrame, data, options);
|
||||
return await this.raw.client.request(frame, options.responseType ?? null);
|
||||
}
|
||||
|
||||
subscribe(type: MTPCommunicationType, handler: (message: ParsedFrame) => void): Unsubscribe {
|
||||
if (typeof type !== "string" || !type) {
|
||||
throw new TypeError("subscription type must be a non-empty string");
|
||||
}
|
||||
if (typeof handler !== "function") {
|
||||
throw new TypeError("subscription handler must be a function");
|
||||
}
|
||||
const id = this.raw.client.subscribe(type, handler);
|
||||
return () => this.raw.client.unsubscribe(id);
|
||||
}
|
||||
|
||||
#handleFrame(frame) {
|
||||
if (isErrorType(frame.type)) {
|
||||
emit(this.#options.logger, {
|
||||
hint: "error",
|
||||
type: frame.type,
|
||||
error: errorMessage(frame),
|
||||
});
|
||||
} else {
|
||||
emit(this.#options.logger, {
|
||||
hint: "info",
|
||||
type: frame.type,
|
||||
data: frame.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.raw.client.stop_protocol_pings();
|
||||
this.raw.client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export { bindings as raw };
|
||||
5
src/type-map/index.ts
Normal file
5
src/type-map/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type MTPCommunicationType = string;
|
||||
export type MTPDataType = string;
|
||||
|
||||
export const communicationTypes: readonly MTPCommunicationType[] = [];
|
||||
export const dataTypes: readonly MTPDataType[] = [];
|
||||
409
src/vite/index.ts
Normal file
409
src/vite/index.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { spawn } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export interface MTPVitePluginOptions {
|
||||
typeMaps: string;
|
||||
release?: boolean;
|
||||
wasmPackArgs?: string[];
|
||||
outDir?: string;
|
||||
}
|
||||
|
||||
export interface VitePlugin {
|
||||
name: string;
|
||||
config?: (...args: any[]) => unknown;
|
||||
buildStart?: (...args: any[]) => unknown;
|
||||
configureServer?: (...args: any[]) => unknown;
|
||||
}
|
||||
|
||||
const packageRoot = process.env.MTP_PACKAGE_ROOT
|
||||
? path.resolve(process.env.MTP_PACKAGE_ROOT)
|
||||
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
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' })");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function devServerPath(root: string, filePath: string) {
|
||||
const relativePath = path.relative(root, filePath);
|
||||
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/${relativePath.split(path.sep).join("/")}`;
|
||||
}
|
||||
|
||||
async function hashPackageInputs() {
|
||||
const hash = crypto.createHash("sha256");
|
||||
const inputs = [
|
||||
"wasm/Cargo.toml",
|
||||
"wasm/src",
|
||||
"common/Cargo.toml",
|
||||
"common/src",
|
||||
"codec/Cargo.toml",
|
||||
"codec/src",
|
||||
"crypto/Cargo.toml",
|
||||
"crypto/src",
|
||||
"type-map/Cargo.toml",
|
||||
"type-map/build.rs",
|
||||
"type-map/src",
|
||||
];
|
||||
|
||||
async function addPath(relativePath) {
|
||||
const absolutePath = path.join(packageRoot, relativePath);
|
||||
const stat = await fs.stat(absolutePath).catch(() => null);
|
||||
if (!stat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const entries = await fs.readdir(absolutePath);
|
||||
for (const entry of entries.sort()) {
|
||||
await addPath(path.join(relativePath, entry));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hash.update(relativePath);
|
||||
hash.update(await fs.readFile(absolutePath));
|
||||
}
|
||||
|
||||
for (const input of inputs) {
|
||||
await addPath(input);
|
||||
}
|
||||
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function quoteList(values) {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
communicationTypes: [...communicationTypes].sort(),
|
||||
dataTypes: [...dataTypes].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function generateTypeMapModule(metadata) {
|
||||
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 };
|
||||
}
|
||||
|
||||
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}`);
|
||||
});
|
||||
const metadata = parseTypeMapYaml(source, typeMapsPath);
|
||||
const module = generateTypeMapModule(metadata);
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
await fs.writeFile(path.join(outDir, typeMapEntryName), module.js);
|
||||
await fs.writeFile(path.join(outDir, "mtp_type_map.d.ts"), module.dts);
|
||||
await fs.writeFile(path.join(outDir, `${typeMapEntryName}.d.ts`), module.dts);
|
||||
return source;
|
||||
}
|
||||
|
||||
async function copyWasmBuildInputs(buildRoot) {
|
||||
const inputs = [
|
||||
"Cargo.lock",
|
||||
"wasm",
|
||||
"common",
|
||||
"codec",
|
||||
"crypto",
|
||||
"type-map",
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
const source = path.join(packageRoot, input);
|
||||
if (!await pathExists(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.cp(source, path.join(buildRoot, input), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) {
|
||||
const buildRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mtp-wasm-"));
|
||||
const args = [
|
||||
"build",
|
||||
path.join(buildRoot, "wasm"),
|
||||
"--target",
|
||||
"web",
|
||||
"--out-dir",
|
||||
outDir,
|
||||
];
|
||||
if (release) {
|
||||
args.push("--release");
|
||||
} else {
|
||||
args.push("--dev");
|
||||
}
|
||||
args.push(...wasmPackArgs);
|
||||
|
||||
try {
|
||||
await copyWasmBuildInputs(buildRoot);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("wasm-pack", args, {
|
||||
cwd: buildRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
MTP_TYPE_MAPS: typeMapsPath,
|
||||
RUSTFLAGS: [process.env.RUSTFLAGS, "--cfg web_sys_unstable_apis"].filter(Boolean).join(" "),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
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."));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`wasm-pack failed with exit code ${code}.\n${stdout}${stderr}`.trim()));
|
||||
}
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(buildRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function buildIfNeeded(state, force = false) {
|
||||
if (state.buildPromise) {
|
||||
return state.buildPromise;
|
||||
}
|
||||
|
||||
state.buildPromise = (async () => {
|
||||
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,
|
||||
}))
|
||||
.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;
|
||||
} catch {
|
||||
previousFingerprint = null;
|
||||
}
|
||||
|
||||
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)");
|
||||
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));
|
||||
})().finally(() => {
|
||||
state.buildPromise = null;
|
||||
});
|
||||
|
||||
return state.buildPromise;
|
||||
}
|
||||
|
||||
export function mtp(options: MTPVitePluginOptions): VitePlugin {
|
||||
const normalized = normalizeOptions(options);
|
||||
const state = {
|
||||
outDir: null,
|
||||
typeMapsPath: null,
|
||||
release: true,
|
||||
wasmPackArgs: normalized.wasmPackArgs ?? [],
|
||||
buildPromise: null,
|
||||
};
|
||||
|
||||
return {
|
||||
name: "mtp",
|
||||
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.release = normalized.release ?? env.command === "build";
|
||||
|
||||
if (!await pathExists(state.typeMapsPath)) {
|
||||
throw new Error(`mtp/vite could not find typeMaps file: ${state.typeMapsPath}`);
|
||||
}
|
||||
|
||||
await buildIfNeeded(state);
|
||||
|
||||
return {
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
alias: {
|
||||
"mtp/raw": path.join(state.outDir, rawEntryName),
|
||||
"mtp/type-map": path.join(state.outDir, typeMapEntryName),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
buildStart() {
|
||||
this.addWatchFile(state.typeMapsPath);
|
||||
},
|
||||
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) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
res.setHeader("Content-Type", "application/wasm");
|
||||
res.end(await fs.readFile(wasmPath));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
server.watcher.add(state.typeMapsPath);
|
||||
server.watcher.on("change", async (changedPath) => {
|
||||
if (path.resolve(changedPath) !== state.typeMapsPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await buildIfNeeded(state, true);
|
||||
server.moduleGraph.invalidateAll();
|
||||
server.ws.send({ type: "full-reload" });
|
||||
} catch (error) {
|
||||
server.config.logger.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const mtpVitePlugin = mtp;
|
||||
export default mtpVitePlugin;
|
||||
Loading…
Reference in a new issue