diff --git a/packages/core/src/utils/transform/fromDevice.ts b/packages/core/src/utils/transform/fromDevice.ts index a21cf6ccf..c4d32566c 100644 --- a/packages/core/src/utils/transform/fromDevice.ts +++ b/packages/core/src/utils/transform/fromDevice.ts @@ -7,10 +7,11 @@ export const fromDeviceStream: () => TransformStream = let byteBuffer = new Uint8Array([]); const textDecoder = new TextDecoder(); return new TransformStream({ - transform(chunk: Uint8Array, controller): void { + transform(raw: Uint8Array | ArrayBuffer, controller): void { // onReleaseEvent.subscribe(() => { // controller.terminate(); // }); + let chunk = raw instanceof Uint8Array ? raw : new Uint8Array(raw); byteBuffer = new Uint8Array([...byteBuffer, ...chunk]); let processingExhausted = false; while (byteBuffer.length !== 0 && !processingExhausted) { diff --git a/packages/transport-ws/README.md b/packages/transport-ws/README.md new file mode 100644 index 000000000..c7e3415d9 --- /dev/null +++ b/packages/transport-ws/README.md @@ -0,0 +1,32 @@ +# @meshtastic/transport-ws + +[![JSR](https://jsr.io/badges/@meshtastic/transport-ws)](https://jsr.io/@meshtastic/transport-ws) +[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/js/ci.yml?branch=master&label=actions&logo=github&color=yellow)](https://github.com/meshtastic/js/actions/workflows/ci.yml) +[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/meshtastic.js)](https://cla-assistant.io/meshtastic/meshtastic.js) +[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/) +[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss) + +## Overview + +`@meshtastic/transport-ws` Provides WebSocket transport for Meshtastic +devices. Installation instructions are avaliable at +[JSR](https://jsr.io/@meshtastic/transport-ws) +[NPM](https://www.npmjs.com/package/@meshtastic/transport-ws) + +## Usage + +```ts +import { MeshDevice } from "@meshtastic/core"; +import { TransportWebSocket } from "@meshtastic/transport-ws"; + +const transport = await TransportWebSocket.createFromUrl(new URL("ws://server:port/endpoint")); +const device = new MeshDevice(transport); +``` + +## Stats + +![Alt](https://repobeats.axiom.co/api/embed/5330641586e92a2ec84676fedb98f6d4a7b25d69.svg "Repobeats analytics image") + +### Compatibility + +The WebSocket API's used here are available almost everywhere. diff --git a/packages/transport-ws/mod.ts b/packages/transport-ws/mod.ts new file mode 100644 index 000000000..46ce38319 --- /dev/null +++ b/packages/transport-ws/mod.ts @@ -0,0 +1 @@ +export { TransportWebSocket } from "./src/transport.ts"; diff --git a/packages/transport-ws/package.json b/packages/transport-ws/package.json new file mode 100644 index 000000000..c9fdeceb8 --- /dev/null +++ b/packages/transport-ws/package.json @@ -0,0 +1,50 @@ +{ + "name": "@meshtastic/transport-ws", + "version": "0.2.5", + "description": "A transport layer for Meshtastic applications using Web Serial API.", + "exports": { + ".": "./mod.ts" + }, + "type": "module", + "main": "./dist/mod.js", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", + "files": [ + "package.json", + "README.md", + "LICENSE", + "dist" + ], + "license": "GPL-3.0-only", + "tsdown": { + "entry": "mod.ts", + "dts": true, + "format": [ + "esm" + ], + "splitting": false, + "clean": true + }, + "jsrInclude": [ + "mod.ts", + "src", + "README.md", + "LICENSE" + ], + "jsrExclude": [ + "src/**/*.test.ts" + ], + "scripts": { + "preinstall": "npx only-allow pnpm", + "prepack": "cp ../../LICENSE ./LICENSE", + "clean": "rm -rf dist LICENSE", + "build:npm": "tsdown", + "publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public --no-git-checks", + "prepare:jsr": "rm -rf dist && pnpm dlx pkg-to-jsr", + "publish:jsr": "pnpm run prepack && pnpm prepare:jsr && deno publish --allow-dirty --no-check" + }, + "dependencies": { + "@meshtastic/core": "workspace:*", + "websocketstream-ponyfill": "^0.1.3" + } +} \ No newline at end of file diff --git a/packages/transport-ws/src/transport.test.ts b/packages/transport-ws/src/transport.test.ts new file mode 100644 index 000000000..e29ba7f2d --- /dev/null +++ b/packages/transport-ws/src/transport.test.ts @@ -0,0 +1,113 @@ +import { Types, Utils } from "@meshtastic/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runTransportContract } from "../../../tests/utils/transportContract.ts"; +import { TransportWebSocket } from "./transport.ts"; + +function stubCoreTransforms() { + const toDevice = () => + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk); + }, + }); + + // maps raw bytes -> DeviceOutput.packet + const fromDeviceFactory = () => + new TransformStream({ + transform(chunk, controller) { + controller.enqueue({ type: "packet", data: chunk }); + }, + }); + + const transform = Utils.toDeviceStream; + const restoreTo = vi + .spyOn(Utils, "toDeviceStream", "get") + .mockReturnValue(toDevice as unknown as typeof transform); + + const restoreFrom = vi + .spyOn(Utils, "fromDeviceStream") + .mockImplementation( + () => + fromDeviceFactory() as unknown as TransformStream< + Uint8Array, + Types.DeviceOutput + >, + ); + + return { + restore: () => { + restoreTo.mockRestore(); + restoreFrom.mockRestore(); + }, + }; +} + +class FakeWebSocketStream { + readable: ReadableStream; + writable: WritableStream; + lastWritten?: Uint8Array; + + private _readController!: ReadableStreamDefaultController; + + constructor() { + this.readable = new ReadableStream({ + start: (controller) => { + this._readController = controller; + }, + }); + + this.writable = new WritableStream({ + write: async (chunk) => { + this.lastWritten = chunk; + }, + }); + } + + open(_options?: { baudRate?: number }): Promise { + return Promise.resolve(); + } + + close(): Promise { + try { + this._readController.close(); + } catch { } + return Promise.resolve(); + } + + pushIncoming(bytes: Uint8Array) { + this._readController.enqueue(bytes); + } +} + +describe("TransportWebSocket (contract)", () => { + let transforms: { restore(): void } | undefined; + + beforeEach(() => { + transforms = stubCoreTransforms(); + }); + + afterEach(() => { + transforms?.restore(); + vi.restoreAllMocks(); + }); + + runTransportContract({ + name: "TransportWebSocket", + setup: () => { }, + teardown: () => { }, + create: async () => { + const fake = new FakeWebSocketStream(); + const transport = new TransportWebSocket(fake as any, fake.readable, fake.writable); + (globalThis as any).__ws = { fake }; + await Promise.resolve(); + return transport; + }, + pushIncoming: async (bytes) => { + (globalThis as any).__ws.fake.pushIncoming(bytes); + await Promise.resolve(); + }, + assertLastWritten: (bytes) => { + expect((globalThis as any).__ws.fake.lastWritten).toEqual(bytes); + }, + }); +}); diff --git a/packages/transport-ws/src/transport.ts b/packages/transport-ws/src/transport.ts new file mode 100644 index 000000000..8b6546ea9 --- /dev/null +++ b/packages/transport-ws/src/transport.ts @@ -0,0 +1,174 @@ +import { Types, Utils } from "@meshtastic/core"; +import { WebSocketStream } from "websocketstream-ponyfill"; + +/** + * Provides Web Serial transport for Meshtastic devices. + * + * Implements the {@link Types.Transport} contract using the Web Serial API. + * Use {@link TransportWebSocket.createFromUrl} + * to construct an instance. + */ +export class TransportWebSocket implements Types.Transport { + private _toDevice: WritableStream; + private _fromDevice: ReadableStream; + private fromDeviceController?: ReadableStreamDefaultController; + private connection: WebSocketStream; + private pipePromise: Promise | null = null; + private abortController: AbortController; + private portReadable: ReadableStream; + + private lastStatus: Types.DeviceStatusEnum = + Types.DeviceStatusEnum.DeviceDisconnected; + private closingByUser = false; + + /** + * Creates a new TransportWebSocket instance from an existing, provided {@link SerialPort}. + * Opens it if not already open. + */ + public static async createFromUrl( + url: URL, + ): Promise { + const ws = new WebSocketStream(url.toString()); + const { readable, writable } = await ws.opened; + return new TransportWebSocket(ws, readable, writable); + } + + /** + * Constructs a transport around a given {@link SerialPort}. + * @throws If the port lacks readable or writable streams. + */ + constructor(connection: WebSocketStream, readable: ReadableStream, writable: WritableStream) { + + if (!readable || !writable) { + throw new Error("Stream not accessible"); + } + + this.connection = connection; + this.portReadable = readable; + this.abortController = new AbortController(); + const abortController = this.abortController; + + // Set up the pipe with abort signal for clean cancellation + const toDeviceTransform = Utils.toDeviceStream(); + this.pipePromise = toDeviceTransform.readable + .pipeTo(writable, { signal: this.abortController.signal }) + .catch((err) => { + // Ignore expected rejection when we cancel it via the AbortController. + if (abortController.signal.aborted) { + return; + } + console.error("Error piping data to web socket:", err); + this.connection.close(); + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "write-error", + ); + }); + + this._toDevice = toDeviceTransform.writable; + + // Wrap + capture controller to inject status packets + this._fromDevice = new ReadableStream({ + start: async (ctrl) => { + this.fromDeviceController = ctrl; + + this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting); + + const transformed = this.portReadable.pipeThrough( + Utils.fromDeviceStream(), + ); + const reader = transformed.getReader(); + + this.emitStatus(Types.DeviceStatusEnum.DeviceConnected); + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + ctrl.enqueue(value); + } + ctrl.close(); + } catch (error) { + if (!this.closingByUser) { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "read-error", + ); + } + ctrl.error(error instanceof Error ? error : new Error(String(error))); + try { + await transformed.cancel(); + } catch { } + } finally { + reader.releaseLock(); + } + }, + }); + } + + /** Writable stream of bytes to the device. */ + public get toDevice(): WritableStream { + return this._toDevice; + } + + /** Readable stream of {@link Types.DeviceOutput} from the device. */ + public get fromDevice(): ReadableStream { + return this._fromDevice; + } + + private emitStatus(next: Types.DeviceStatusEnum, reason?: string): void { + if (next === this.lastStatus) { + return; + } + this.lastStatus = next; + this.fromDeviceController?.enqueue({ + type: "status", + data: { status: next, reason }, + }); + } + + /** + * Closes the serial port and emits `DeviceDisconnected("user")`. + */ + public async disconnect(): Promise { + try { + this.closingByUser = true; + + // Stop outbound piping + this.abortController.abort(); + if (this.pipePromise) { + await this.pipePromise; + } + + // Cancel any remaining streams + if (this._fromDevice?.locked) { + try { + await this._fromDevice.cancel(); + } catch { + // Stream cancellation might fail if already cancelled + } + } + + await this.connection.close(); + } catch (error) { + // If we can't close cleanly, let the browser handle cleanup + console.warn("Could not cleanly disconnect web socket:", error); + } finally { + this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "user"); + this.closingByUser = false; + } + } + + /** + * Reconnects the transport by creating a new AbortController and re-establishing + * the pipe connection. Only call this after disconnect() or if the connection failed. + */ + public async reconnect() { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "reconnect-failed", + ); + } +} diff --git a/packages/transport-ws/tsconfig.json b/packages/transport-ws/tsconfig.json new file mode 100644 index 000000000..6f637fd63 --- /dev/null +++ b/packages/transport-ws/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "target": "ES2020", + "declaration": true, + "outDir": "./dist", + "moduleResolution": "bundler", + "emitDeclarationOnly": false, + "esModuleInterop": true, + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/web/package.json b/packages/web/package.json index c66c60ebf..baa54ccf3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -33,6 +33,7 @@ "@meshtastic/transport-http": "workspace:*", "@meshtastic/transport-web-bluetooth": "workspace:*", "@meshtastic/transport-web-serial": "workspace:*", + "@meshtastic/transport-ws": "workspace:*", "@noble/curves": "^1.9.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -113,4 +114,4 @@ "typescript": "^5.9.3", "vitest": "^3.2.4" } -} +} \ No newline at end of file diff --git a/packages/web/src/components/Dialog/AddConnectionDialog/AddConnectionDialog.tsx b/packages/web/src/components/Dialog/AddConnectionDialog/AddConnectionDialog.tsx index 38332c7f3..ed8ee5200 100644 --- a/packages/web/src/components/Dialog/AddConnectionDialog/AddConnectionDialog.tsx +++ b/packages/web/src/components/Dialog/AddConnectionDialog/AddConnectionDialog.tsx @@ -26,6 +26,7 @@ import { Bluetooth, Cable, CheckCircle2, + EthernetPort, Globe, Loader2, type LucideIcon, @@ -42,12 +43,12 @@ type TestingStatus = "idle" | "testing" | "success" | "failure"; type DialogState = { tab: TabKey; name: string; - protocol: "http" | "https"; + protocol: "http" | "https" | "ws" | "wss"; url: string; testStatus: TestingStatus; btSelected: - | { id: string; name?: string; device?: BluetoothDevice } - | undefined; + | { id: string; name?: string; device?: BluetoothDevice } + | undefined; serialSelected: { vendorId?: number; productId?: number } | undefined; }; @@ -55,19 +56,19 @@ type DialogAction = | { type: "RESET"; payload?: { isHTTPS?: boolean } } | { type: "SET_TAB"; payload: TabKey } | { type: "SET_NAME"; payload: string } - | { type: "SET_PROTOCOL"; payload: "http" | "https" } + | { type: "SET_PROTOCOL"; payload: "http" | "https" | "ws" | "wss" } | { type: "SET_URL"; payload: string } | { type: "SET_TEST_STATUS"; payload: TestingStatus } | { - type: "SET_BT_SELECTED"; - payload: - | { id: string; name?: string; device?: BluetoothDevice } - | undefined; - } + type: "SET_BT_SELECTED"; + payload: + | { id: string; name?: string; device?: BluetoothDevice } + | undefined; + } | { - type: "SET_SERIAL_SELECTED"; - payload: { vendorId?: number; productId?: number } | undefined; - } + type: "SET_SERIAL_SELECTED"; + payload: { vendorId?: number; productId?: number } | undefined; + } | { type: "SET_URL_AND_RESET_TEST"; payload: string }; interface FeatureErrorProps { @@ -83,20 +84,20 @@ type Pane = { }; const featureErrors: Record = - { - "Web Bluetooth": { - href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility", - i18nKey: "addConnection.validation.requiresWebBluetooth", - }, - "Web Serial": { - href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility", - i18nKey: "addConnection.validation.requiresWebSerial", - }, - "Secure Context": { - href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts", - i18nKey: "addConnection.validation.requiresSecureContext", - }, - }; +{ + "Web Bluetooth": { + href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility", + i18nKey: "addConnection.validation.requiresWebBluetooth", + }, + "Web Serial": { + href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility", + i18nKey: "addConnection.validation.requiresWebSerial", + }, + "Secure Context": { + href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts", + i18nKey: "addConnection.validation.requiresSecureContext", + }, +}; const FeatureErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => { if (missingFeatures.length === 0) { @@ -245,14 +246,15 @@ function PickerRow({ const TAB_META: Array<{ key: TabKey; label: string; Icon: LucideIcon }> = [ { key: "http", label: "HTTP", Icon: Globe }, + { key: "ws", label: "WebSocket", Icon: EthernetPort }, { key: "bluetooth", label: "Bluetooth", Icon: Bluetooth }, { key: "serial", label: "Serial", Icon: Cable }, ]; export default function AddConnectionDialog({ open = false, - onOpenChange = () => {}, - onSave = async () => {}, + onOpenChange = () => { }, + onSave = async () => { }, isHTTPS = false, }: { open?: boolean; @@ -318,8 +320,8 @@ export default function AddConnectionDialog({ type: "SET_NAME", payload: device.name ? t("addConnection.bluetoothConnection.short", { - deviceName: device.name, - }) + deviceName: device.name, + }) : t("addConnection.bluetoothConnection.long"), }); } @@ -380,8 +382,7 @@ export default function AddConnectionDialog({ }, [serialSupported, state.name, toast, makeToastErrorHandler, t]); const handleTestHttp = useCallback(async () => { - const fullUrl = `${state.protocol}://${state.url}`; - const validatedURL = urlOrIpv4Schema.safeParse(fullUrl); + const validatedURL = urlOrIpv4Schema.safeParse(state.url); if (validatedURL.success === false) { toast({ title: t("addConnection.httpConnection.invalidUrl.title"), @@ -404,6 +405,19 @@ export default function AddConnectionDialog({ } }, [state.protocol, state.url, toast, t]); + const handleTestWs = useCallback(async () => { + const validatedURL = urlOrIpv4Schema.safeParse(state.url); + if (validatedURL.success === false) { + toast({ + title: t("addConnection.wsConnection.invalidUrl.title"), + description: t("addConnection.wsConnection.invalidUrl.description") + validatedURL.error + state.url, + }); + return; + } + dispatch({ type: "SET_TEST_STATUS", payload: "testing" }); + dispatch({ type: "SET_TEST_STATUS", payload: "success" }); + }, [state.protocol, state.url, toast, t]); + const PANES: Record = useMemo( () => ({ http: { @@ -448,7 +462,7 @@ export default function AddConnectionDialog({ className="gap-2" onClick={handleTestHttp} disabled={ - urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`) + urlOrIpv4Schema.safeParse(`${state.url}`) .success === false || state.testStatus === "testing" } > @@ -489,7 +503,7 @@ export default function AddConnectionDialog({ ), validate: () => - urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`) + urlOrIpv4Schema.safeParse(`${state.url}`) .success === true && state.testStatus === "success", build: () => ({ type: "http", @@ -497,6 +511,93 @@ export default function AddConnectionDialog({ url: `${state.protocol}://${state.url.trim()}`, }), }, + ws: { + placeholder: t("addConnection.wsConnection.namePlaceholder"), + children: () => ( +
+ + + { + dispatch({ + type: "SET_URL_AND_RESET_TEST", + payload: e.target.value, + }); + }} + /> +
+ { + dispatch({ + type: "SET_PROTOCOL", + payload: value ? "wss" : "ws", + }); + dispatch({ type: "SET_TEST_STATUS", payload: "idle" }); + }} + > + +
+
+ + {state.testStatus === "success" && ( +
+ + {t("addConnection.wsConnection.connectionTest.reachable")} +
+ )} + {state.testStatus === "failure" && ( +
+ + {t( + "addConnection.wsConnection.connectionTest.notReachable", + )} +
+ )} +
+

+ {t("addConnection.wsConnection.connectionTest.description")} +

+
+ ), + validate: () => + urlOrIpv4Schema.safeParse(`${state.url}`) + .success === true && state.testStatus === "success", + build: () => ({ + type: "ws", + name: state.name.trim(), + url: `${state.protocol}://${state.url.trim()}`, + }), + }, bluetooth: { placeholder: "My Bluetooth Node", children: () => ( @@ -558,11 +659,11 @@ export default function AddConnectionDialog({ display={ state.serialSelected ? t("addConnection.serialConnection.deviceName", { - vendorId: - state.serialSelected.vendorId?.toString(16) ?? "?", - productId: - state.serialSelected.productId?.toString(16) ?? "?", - }) + vendorId: + state.serialSelected.vendorId?.toString(16) ?? "?", + productId: + state.serialSelected.productId?.toString(16) ?? "?", + }) : t("addConnection.serialConnection.notSelected") } helper={t("addConnection.serialConnection.helperText")} @@ -599,19 +700,19 @@ export default function AddConnectionDialog({ const submit = (fn: (p: NewConnection, device?: BluetoothDevice) => Promise) => - async () => { - if (!canCreate) { - return; - } - const payload = currentPane.build(); + async () => { + if (!canCreate) { + return; + } + const payload = currentPane.build(); - if (!payload) { - return; - } - const btDevice = - state.tab === "bluetooth" ? state.btSelected?.device : undefined; - await fn(payload, btDevice); - }; + if (!payload) { + return; + } + const btDevice = + state.tab === "bluetooth" ? state.btSelected?.device : undefined; + await fn(payload, btDevice); + }; return ( { - const input = val.replace(/^https?:\/\//i, ""); - + .refine((input) => { // Split input into host and port (port is optional) - const lastColonIndex = input.lastIndexOf(":"); - let host = input; + const firstSlashIndex = input.indexOf("/"); + const host_port = (firstSlashIndex === -1) ? input : input.substring(0, firstSlashIndex); + const lastColonIndex = host_port.lastIndexOf(":"); + let host = host_port; let port = null; if (lastColonIndex !== -1) { - const potentialPort = input.substring(lastColonIndex + 1); + const potentialPort = host_port.substring(lastColonIndex + 1); if (/^\d+$/.test(potentialPort)) { - host = input.substring(0, lastColonIndex); + host = host_port.substring(0, lastColonIndex); port = parseInt(potentialPort, 10); } } @@ -40,7 +40,4 @@ export const urlOrIpv4Schema = z domainRegex.test(host) || localDomainRegex.test(host) ); - }, "Must be a valid IPv4 address or domain name with optional port (10-65535)") - .transform((val) => { - return /^https?:\/\//i.test(val) ? val : `http://${val}`; - }); + }, "Must be a valid IPv4 address or domain name with optional port (10-65535)"); diff --git a/packages/web/src/core/stores/deviceStore/types.ts b/packages/web/src/core/stores/deviceStore/types.ts index 484f3b315..ac311ac7a 100644 --- a/packages/web/src/core/stores/deviceStore/types.ts +++ b/packages/web/src/core/stores/deviceStore/types.ts @@ -29,7 +29,7 @@ type DialogVariant = keyof Dialogs; type Page = "messages" | "map" | "settings" | "channels" | "nodes"; export type ConnectionId = number; -export type ConnectionType = "http" | "bluetooth" | "serial"; +export type ConnectionType = "http" | "bluetooth" | "serial" | "ws"; export type ConnectionStatus = | "connected" | "connecting" @@ -55,18 +55,23 @@ export type Connection = { export type NewConnection = | { type: "http"; name: string; url: string } | { - type: "bluetooth"; - name: string; - deviceId?: string; - deviceName?: string; - gattServiceUUID?: string; - } + type: "bluetooth"; + name: string; + deviceId?: string; + deviceName?: string; + gattServiceUUID?: string; + } | { - type: "serial"; - name: string; - usbVendorId?: number; - usbProductId?: number; - }; + type: "serial"; + name: string; + usbVendorId?: number; + usbProductId?: number; + } + | { + type: "ws"; + name: string; + url: URL; + }; type WaypointWithMetadata = Protobuf.Mesh.Waypoint & { metadata: { diff --git a/packages/web/src/pages/Connections/index.tsx b/packages/web/src/pages/Connections/index.tsx index 27c413c30..63c3578d3 100644 --- a/packages/web/src/pages/Connections/index.tsx +++ b/packages/web/src/pages/Connections/index.tsx @@ -152,9 +152,9 @@ export const Connections = () => { title: ok ? t("toasts.connected") : t("toasts.failed"), description: ok ? t("toasts.nowConnected", { - name: c.name, - interpolation: { escapeValue: false }, - }) + name: c.name, + interpolation: { escapeValue: false }, + }) : t("toasts.checkConnection"), }); if (ok) { @@ -198,9 +198,9 @@ export const Connections = () => { title: ok ? t("toasts.connected") : t("toasts.failed"), description: ok ? t("toasts.nowConnected", { - name: c.name, - interpolation: { escapeValue: false }, - }) + name: c.name, + interpolation: { escapeValue: false }, + }) : t("toasts.pickConnectionAgain"), }); if (ok) { @@ -248,7 +248,7 @@ export const Connections = () => { function TypeBadge({ type }: { type: Connection["type"] }) { const Icon = connectionTypeIcon(type); const label = - type === "http" ? "HTTP" : type === "bluetooth" ? "Bluetooth" : "Serial"; + type === "http" ? "HTTP" : type === "ws" ? "WS" : type === "bluetooth" ? "Bluetooth" : type === "serial" ? "Serial" : "Unknown"; return ( diff --git a/packages/web/src/pages/Connections/useConnections.ts b/packages/web/src/pages/Connections/useConnections.ts index 206e24ab0..2446dda2d 100644 --- a/packages/web/src/pages/Connections/useConnections.ts +++ b/packages/web/src/pages/Connections/useConnections.ts @@ -20,6 +20,7 @@ import { MeshDevice } from "@meshtastic/core"; import { TransportHTTP } from "@meshtastic/transport-http"; import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; import { TransportWebSerial } from "@meshtastic/transport-web-serial"; +import { TransportWebSocket } from "@meshtastic/transport-ws"; import { useCallback } from "react"; // Local storage for cleanup only (not in Zustand) @@ -91,7 +92,7 @@ export function useConnections() { // Disconnect MeshDevice try { device.connection.disconnect(); - } catch {} + } catch { } } // Close transport if it's BT or Serial @@ -101,14 +102,14 @@ export function useConnections() { if (bt.gatt?.connected) { try { bt.gatt.disconnect(); - } catch {} + } catch { } } const sp = transport as SerialPort & { close?: () => Promise }; if (sp.close) { try { sp.close(); - } catch {} + } catch { } } transports.delete(id); @@ -117,7 +118,7 @@ export function useConnections() { // Clean up orphaned Device try { removeDevice(conn.meshDeviceId); - } catch {} + } catch { } } removeSavedConnectionFromStore(id); @@ -144,7 +145,8 @@ export function useConnections() { transport: | Awaited> | Awaited> - | Awaited>, + | Awaited> + | Awaited>, btDevice?: BluetoothDevice, serialPort?: SerialPort, ): number => { @@ -404,6 +406,13 @@ export function useConnections() { // Status will be set to "configured" by onConfigComplete event return true; } + + if (conn.type === "ws") { + const transport = await TransportWebSocket.createFromUrl(conn.url); + setupMeshDevice(id, transport); + // Status will be set to "configured" by onConfigComplete event + return true; + } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); updateStatus(id, "error", message); diff --git a/packages/web/src/pages/Connections/utils.ts b/packages/web/src/pages/Connections/utils.ts index d55d9fd2c..70e44ccc0 100644 --- a/packages/web/src/pages/Connections/utils.ts +++ b/packages/web/src/pages/Connections/utils.ts @@ -5,7 +5,7 @@ import type { NewConnection, } from "@app/core/stores/deviceStore/types"; import { randId } from "@app/core/utils/randId"; -import { Bluetooth, Cable, Globe, type LucideIcon } from "lucide-react"; +import { Bluetooth, Cable, Globe, EthernetPort, type LucideIcon } from "lucide-react"; export function createConnectionFromInput(input: NewConnection): Connection { const base = { @@ -32,12 +32,24 @@ export function createConnectionFromInput(input: NewConnection): Connection { gattServiceUUID: input.gattServiceUUID, }; } - return { - ...base, - type: "serial", - usbVendorId: input.usbVendorId, - usbProductId: input.usbProductId, - }; + if (input.type === "serial") { + return { + ...base, + type: "serial", + usbVendorId: input.usbVendorId, + usbProductId: input.usbProductId, + }; + } + if (input.type === "ws") { + return { + ...base, + type: "ws", + url: input.url, + isDefault: false, + name: input.name.length === 0 ? input.url.toString() : input.name, + }; + } + throw new Error(`Unknown connection type: ${input.type}`); } export async function testHttpReachable( @@ -68,7 +80,12 @@ export function connectionTypeIcon(type: ConnectionType): LucideIcon { if (type === "bluetooth") { return Bluetooth; } - return Cable; + if (type === "serial") { + return Cable; + } + if (type === "ws") { + return EthernetPort; + } } export function formatConnectionSubtext(conn: Connection): string { @@ -78,7 +95,12 @@ export function formatConnectionSubtext(conn: Connection): string { if (conn.type === "bluetooth") { return conn.deviceName || conn.deviceId || "No device selected"; } - const v = conn.usbVendorId ? conn.usbVendorId.toString(16) : "?"; - const p = conn.usbProductId ? conn.usbProductId.toString(16) : "?"; - return `USB ${v}:${p}`; + if (conn.type === "serial") { + const v = conn.usbVendorId ? conn.usbVendorId.toString(16) : "?"; + const p = conn.usbProductId ? conn.usbProductId.toString(16) : "?"; + return `USB ${v}:${p}`; + } + if (conn.type === "ws") { + return conn.url.toString(); + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 479805822..641b9a757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,15 @@ importers: specifier: npm:@types/w3c-web-serial@^1.0.7 version: 1.0.8 + packages/transport-ws: + dependencies: + '@meshtastic/core': + specifier: workspace:* + version: link:../core + websocketstream-ponyfill: + specifier: ^0.1.3 + version: 0.1.3 + packages/ui: dependencies: '@radix-ui/react-collapsible': @@ -196,6 +205,9 @@ importers: '@meshtastic/transport-web-serial': specifier: workspace:* version: link:../transport-web-serial + '@meshtastic/transport-ws': + specifier: workspace:* + version: link:../transport-ws '@noble/curves': specifier: ^1.9.2 version: 1.9.6 @@ -1548,8 +1560,8 @@ packages: '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} '@noble/curves@1.9.6': resolution: {integrity: sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==} @@ -1571,12 +1583,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@oxc-project/runtime@0.71.0': - resolution: {integrity: sha512-QwoF5WUXIGFQ+hSxWEib4U/aeLoiDN9JlP18MnBgx9LLPRDfn1iICtcow7Jgey6HLH4XFceWXQD5WBJ39dyJcw==} - engines: {node: '>=6.9.0'} - - '@oxc-project/types@0.71.0': - resolution: {integrity: sha512-5CwQ4MI+P4MQbjLWXgNurA+igGwu/opNetIE13LBs9+V93R64MLvDKOOLZIXSzEfovU3Zef3q3GjPnMTgJTn2w==} + '@oxc-project/types@0.108.0': + resolution: {integrity: sha512-7lf13b2IA/kZO6xgnIZA88sq3vwrxWk+2vxf6cc+omwYCRTiA5e63Beqf3fz/v8jEviChWWmFYBwzfSeyrsj7Q==} '@publint/pack@0.1.2': resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==} @@ -2110,67 +2118,84 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@rolldown/binding-darwin-arm64@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-Mp0/gqiPdepHjjVm7e0yL1acWvI0rJVVFQEADSezvAjon9sjQ7CEg9JnXICD4B1YrPmN9qV/e7cQZCp87tTV4w==} + '@rolldown/binding-android-arm64@1.0.0-beta.60': + resolution: {integrity: sha512-hOW6iQXtpG4uCW1zGK56+KhEXGttSkTp2ykncW/nkOIF/jOKTqbM944Q73HVeMXP1mPRvE2cZwNp3xeLIeyIGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.60': + resolution: {integrity: sha512-vyDA4HXY2mP8PPtl5UE17uGPxUNG4m1wkfa3kAkR8JWrFbarV97UmLq22IWrNhtBPa89xqerzLK8KoVmz5JqCQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-40re4rMNrsi57oavRzIOpRGmg3QRlW6Ea8Q3znaqgOuJuKVrrm2bIQInTfkZJG7a4/5YMX7T951d0+toGLTdCA==} + '@rolldown/binding-darwin-x64@1.0.0-beta.60': + resolution: {integrity: sha512-WnxyqxAKP2BsxouwGY/RCF5UFw/LA4QOHhJ7VEl+UCelHokiwqNHRbryLAyRy3TE1FZ5eae+vAFcaetAu/kWLw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-8BDM939bbMariZupiHp3OmP5N+LXPT4mULA0hZjDaq970PCxv4krZOSMG+HkWUUwmuQROtV+/00xw39EO0P+8g==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.60': + resolution: {integrity: sha512-JtyWJ+zXOHof5gOUYwdTWI2kL6b8q9eNwqB/oD4mfUFaC/COEB2+47JMhcq78dey9Ahmec3DZKRDZPRh9hNAMQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-sntsPaPgrECpBB/+2xrQzVUt0r493TMPI+4kWRMhvMsmrxOqH1Ep5lM0Wua/ZdbfZNwm1aVa5pcESQfNfM4Fhw==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.60': + resolution: {integrity: sha512-LrMoKqpHx+kCaNSk84iSBd4yVOymLIbxJQtvFjDN2CjQraownR+IXcwYDblFcj9ivmS54T3vCboXBbm3s1zbPQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-5clBW/I+er9F2uM1OFjJFWX86y7Lcy0M+NqsN4s3o07W+8467Zk8oQa4B45vdaXoNUF/yqIAgKkA/OEdQDxZqA==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.60': + resolution: {integrity: sha512-sqI+Vdx1gmXJMsXN3Fsewm3wlt7RHvRs1uysSp//NLsCoh9ZFEUr4ZzGhWKOg6Rvf+njNu/vCsz96x7wssLejQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-wv+rnAfQDk9p/CheX8/Kmqk2o1WaFa4xhWI9gOyDMk/ljvOX0u0ubeM8nI1Qfox7Tnh71eV5AjzSePXUhFOyOg==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.60': + resolution: {integrity: sha512-8xlqGLDtTP8sBfYwneTDu8+PRm5reNEHAuI/+6WPy9y350ls0KTFd3EJCOWEXWGW0F35ko9Fn9azmurBTjqOrQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-gxD0/xhU4Py47IH3bKZbWtvB99tMkUPGPJFRfSc5UB9Osoje0l0j1PPbxpUtXIELurYCqwLBKXIMTQGifox1BQ==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.60': + resolution: {integrity: sha512-iR4nhVouVZK1CiGGGyz+prF5Lw9Lmz30Rl36Hajex+dFVFiegka604zBwzTp5Tl0BZnr50ztnVJ30tGrBhDr8Q==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-HotuVe3XUjDwqqEMbm3o3IRkP9gdm8raY/btd/6KE3JGLF/cv4+3ff1l6nOhAZI8wulWDPEXPtE7v+HQEaTXnA==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.60': + resolution: {integrity: sha512-HbfNcqNeqxFjSMf1Kpe8itr2e2lr0Bm6HltD2qXtfU91bSSikVs9EWsa1ThshQ1v2ZvxXckGjlVLtah6IoslPg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-8Cx+ucbd8n2dIr21FqBh6rUvTVL0uTgEtKR7l+MUZ5BgY4dFh1e4mPVX8oqmoYwOxBiXrsD2JIOCz4AyKLKxWA==} - engines: {node: '>=14.21.3'} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.60': + resolution: {integrity: sha512-BiiamFcgTJ+ZFOUIMO9AHXUo9WXvHVwGfSrJ+Sv0AsTd2w3VN7dJGiH3WRcxKFetljJHWvGbM4fdpY5lf6RIvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.60': + resolution: {integrity: sha512-6roXGbHMdR2ucnxXuwbmQvk8tuYl3VGu0yv13KxspyKBxxBd4RS6iykzLD6mX2gMUHhfX8SVWz7n/62gfyKHow==} + engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-Vhq5vikrVDxAa75fxsyqj0c0Y/uti/TwshXI71Xb8IeUQJOBnmLUsn5dgYf5ljpYYkNa0z9BPAvUDIDMmyDi+w==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.60': + resolution: {integrity: sha512-JBOm8/DC/CKnHyMHoJFdvzVHxUixid4dGkiTqGflxOxO43uSJMpl77pSPXvzwZ/VXwqblU2V0/PanyCBcRLowQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-lN7RIg9Iugn08zP2aZN9y/MIdG8iOOCE93M1UrFlrxMTqPf8X+fDzmR/OKhTSd1A2pYNipZHjyTcb5H8kyQSow==} - cpu: [ia32] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-7/7cLIn48Y+EpQ4CePvf8reFl63F15yPUlg4ZAhl+RXJIfydkdak1WD8Ir3AwAO+bJBXzrfNL+XQbxm0mcQZmw==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.60': + resolution: {integrity: sha512-MKF0B823Efp+Ot8KsbwIuGhKH58pf+2rSM6VcqyNMlNBHheOM0Gf7JmEu+toc1jgN6fqjH7Et+8hAzsLVkIGfA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2180,8 +2205,8 @@ packages: '@rolldown/pluginutils@1.0.0-beta.38': resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==} - '@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5': - resolution: {integrity: sha512-8sExkWRK+zVybw3+2/kBkYBFeLnEUWz1fT7BLHplpzmtqkOfTbAQ9gkt4pzwGIIZmg4Qn5US5ACjUBenrhezwQ==} + '@rolldown/pluginutils@1.0.0-beta.60': + resolution: {integrity: sha512-Jz4aqXRPVtqkH1E3jRDzLO5cgN5JwW+WG0wXGE4NiJd25nougv/AHzxmKCzmVQUYnxLmTM0M4wrZp+LlC2FKLg==} '@rollup/plugin-babel@5.3.1': resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} @@ -5098,8 +5123,9 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-beta.9-commit.d91dfb5: - resolution: {integrity: sha512-FHkj6gGEiEgmAXQchglofvUUdwj2Oiw603Rs+zgFAnn9Cb7T7z3fiaEc0DbN3ja4wYkW6sF2rzMEtC1V4BGx/g==} + rolldown@1.0.0-beta.60: + resolution: {integrity: sha512-YYgpv7MiTp9LdLj1fzGzCtij8Yi2OKEc3HQtfbIxW4yuSgpQz9518I69U72T5ErPA/ATOXqlcisiLrWy+5V9YA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true rollup@2.79.2: @@ -5807,6 +5833,9 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + websocketstream-ponyfill@0.1.3: + resolution: {integrity: sha512-aAjpehBU69xyYm4ElkUdJSVwK5pAa6RAakq73vkCF/NnA/j2QbZawmhVWlRcQzGE/1hVZN/hhQy+AmAL9SQ2Ag==} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -7178,7 +7207,7 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} - '@napi-rs/wasm-runtime@0.2.12': + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.7.1 '@emnapi/runtime': 1.7.1 @@ -7203,9 +7232,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@oxc-project/runtime@0.71.0': {} - - '@oxc-project/types@0.71.0': {} + '@oxc-project/types@0.108.0': {} '@publint/pack@0.1.2': {} @@ -8088,49 +8115,52 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@rolldown/binding-darwin-arm64@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-android-arm64@1.0.0-beta.60': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-darwin-arm64@1.0.0-beta.60': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-darwin-x64@1.0.0-beta.60': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-freebsd-x64@1.0.0-beta.60': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.60': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.60': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.60': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.60': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.9-commit.d91dfb5': - dependencies: - '@napi-rs/wasm-runtime': 0.2.12 + '@rolldown/binding-linux-x64-musl@1.0.0-beta.60': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.60': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.60': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.60': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.9-commit.d91dfb5': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.60': optional: true '@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.38': {} - '@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5': {} + '@rolldown/pluginutils@1.0.0-beta.60': {} '@rollup/plugin-babel@5.3.1(@babel/core@7.28.4)(@types/babel__core@7.20.5)(rollup@2.79.2)': dependencies: @@ -11828,7 +11858,7 @@ snapshots: robust-predicates@3.0.2: {} - rolldown-plugin-dts@0.16.3(rolldown@1.0.0-beta.9-commit.d91dfb5)(typescript@5.9.2): + rolldown-plugin-dts@0.16.3(rolldown@1.0.0-beta.60)(typescript@5.9.2): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.4 @@ -11838,32 +11868,31 @@ snapshots: debug: 4.4.3 dts-resolver: 2.1.2 get-tsconfig: 4.10.1 - rolldown: 1.0.0-beta.9-commit.d91dfb5 + rolldown: 1.0.0-beta.60 optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: - oxc-resolver - supports-color - rolldown@1.0.0-beta.9-commit.d91dfb5: - dependencies: - '@oxc-project/runtime': 0.71.0 - '@oxc-project/types': 0.71.0 - '@rolldown/pluginutils': 1.0.0-beta.9-commit.d91dfb5 - ansis: 4.2.0 - optionalDependencies: - '@rolldown/binding-darwin-arm64': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-darwin-x64': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.9-commit.d91dfb5 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.9-commit.d91dfb5 + rolldown@1.0.0-beta.60: + dependencies: + '@oxc-project/types': 0.108.0 + '@rolldown/pluginutils': 1.0.0-beta.60 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.60 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.60 + '@rolldown/binding-darwin-x64': 1.0.0-beta.60 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.60 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.60 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.60 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.60 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.60 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.60 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.60 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.60 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.60 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.60 rollup@2.79.2: optionalDependencies: @@ -12328,8 +12357,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.9-commit.d91dfb5 - rolldown-plugin-dts: 0.16.3(rolldown@1.0.0-beta.9-commit.d91dfb5)(typescript@5.9.2) + rolldown: 1.0.0-beta.60 + rolldown-plugin-dts: 0.16.3(rolldown@1.0.0-beta.60)(typescript@5.9.2) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -12744,6 +12773,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + websocketstream-ponyfill@0.1.3: {} + whatwg-mimetype@3.0.0: {} whatwg-url@5.0.0: diff --git a/tests/utils/transportContract.ts b/tests/utils/transportContract.ts index bafdb92fc..cb5aa3c60 100644 --- a/tests/utils/transportContract.ts +++ b/tests/utils/transportContract.ts @@ -98,25 +98,27 @@ export function runTransportContract(contract: TransportContract) { const reader = transport.fromDevice.getReader(); - await contract.triggerDisconnect?.(); - - // As above, read a few events and assert we eventually see "disconnected" - let sawDrop = false; - for (let i = 0; i < 10; i++) { - const { value } = await reader.read(); - if ( - value && - value.type === "status" && - value.data.status === Types.DeviceStatusEnum.DeviceDisconnected - ) { - sawDrop = true; - break; + if (contract.triggerDisconnect) { + await contract.triggerDisconnect?.(); + + // As above, read a few events and assert we eventually see "disconnected" + let sawDrop = false; + for (let i = 0; i < 10; i++) { + const { value } = await reader.read(); + if ( + value && + value.type === "status" && + value.data.status === Types.DeviceStatusEnum.DeviceDisconnected + ) { + sawDrop = true; + break; + } } - } - expect(sawDrop).toBe(true); + expect(sawDrop).toBe(true); - reader.releaseLock(); - await contract.teardown?.(); + reader.releaseLock(); + await contract.teardown?.(); + } }); }); }