web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

web.ts (16828B)


      1 import { err_msg, type ResolveErrorMsg } from "@radroots/utils";
      2 import { BLE_ERROR, type BleErrorMessage } from "./error.js";
      3 import { ble_message_buffer_source, ble_message_bytes } from "./messages.js";
      4 import type {
      5     BleAvailability,
      6     BleAvailabilityState,
      7     BleConnectOptions,
      8     BleDeviceInfo,
      9     BleDisconnectHandler,
     10     BleMessageHandler,
     11     BleMessageInput,
     12     BlePermissionState,
     13     BleServiceProfile,
     14     BleWriteOptions,
     15     IBle,
     16     IBleSession
     17 } from "./types.js";
     18 import { BLE_MESSAGE_PROFILE } from "./types.js";
     19 
     20 type BlePermissionName = PermissionName | "bluetooth" | "bluetooth-le";
     21 
     22 type BlePermissionDescriptor = {
     23     name: BlePermissionName;
     24 };
     25 
     26 interface PermissionsBle {
     27     query(permission_desc: BlePermissionDescriptor): Promise<PermissionStatus>;
     28 }
     29 
     30 interface NavigatorWithPermissions extends Navigator {
     31     permissions: PermissionsBle;
     32 }
     33 
     34 interface BleBluetooth {
     35     requestDevice(options: BleRequestDeviceOptions): Promise<BleDevice>;
     36     getAvailability?(): Promise<boolean>;
     37 }
     38 
     39 interface NavigatorWithBluetooth extends Navigator {
     40     bluetooth: BleBluetooth;
     41 }
     42 
     43 type BleRequestDeviceFilter = {
     44     services?: string[];
     45     namePrefix?: string;
     46 };
     47 
     48 type BleRequestDeviceOptions = {
     49     filters?: BleRequestDeviceFilter[];
     50     acceptAllDevices?: boolean;
     51     optionalServices?: string[];
     52 };
     53 
     54 interface BleDevice {
     55     id: string;
     56     name?: string;
     57     gatt?: BleGattServer;
     58     addEventListener(type: "gattserverdisconnected", listener: () => void, options?: AddEventListenerOptions): void;
     59     removeEventListener(type: "gattserverdisconnected", listener: () => void, options?: EventListenerOptions): void;
     60 }
     61 
     62 interface BleGattServer {
     63     connect(): Promise<BleGattServer>;
     64     disconnect(): void;
     65     getPrimaryService(uuid: string): Promise<BleGattService>;
     66     connected: boolean;
     67     device: BleDevice;
     68 }
     69 
     70 interface BleGattService {
     71     getCharacteristic(uuid: string): Promise<BleGattCharacteristic>;
     72 }
     73 
     74 interface BleGattCharacteristic {
     75     startNotifications(): Promise<BleGattCharacteristic>;
     76     stopNotifications?(): Promise<void>;
     77     writeValue(value: BufferSource): Promise<void>;
     78     writeValueWithoutResponse?(value: BufferSource): Promise<void>;
     79     value?: DataView | null;
     80     addEventListener(type: "characteristicvaluechanged", listener: (event: Event) => void, options?: AddEventListenerOptions): void;
     81     removeEventListener(type: "characteristicvaluechanged", listener: (event: Event) => void): void;
     82 }
     83 
     84 type BleWaitResult<T> = { value: T } | { timeout: true };
     85 
     86 const has_permissions_api = (nav: Navigator): nav is NavigatorWithPermissions => "permissions" in nav;
     87 
     88 const has_bluetooth = (nav: Navigator): nav is NavigatorWithBluetooth => "bluetooth" in nav;
     89 
     90 const read_permission_state = async (nav: Navigator): Promise<BlePermissionState> => {
     91     if (!has_permissions_api(nav)) return "unknown";
     92     try {
     93         const status = await nav.permissions.query({ name: "bluetooth" });
     94         return status.state;
     95     } catch {
     96         try {
     97             const status = await nav.permissions.query({ name: "bluetooth-le" });
     98             return status.state;
     99         } catch {
    100             return "unknown";
    101         }
    102     }
    103 };
    104 
    105 const read_availability = (): BleAvailability => {
    106     const window_available = typeof window !== "undefined";
    107     const navigator_available = typeof navigator !== "undefined";
    108     const secure_context = window_available && window.isSecureContext === true;
    109     const bluetooth_available = navigator_available && has_bluetooth(navigator);
    110     return {
    111         supported: window_available && navigator_available && secure_context && bluetooth_available,
    112         secure_context,
    113         window_available,
    114         navigator_available,
    115         bluetooth_available
    116     };
    117 };
    118 
    119 const read_adapter_availability = async (bluetooth: BleBluetooth): Promise<boolean | "unknown"> => {
    120     if (!bluetooth.getAvailability) return "unknown";
    121     try {
    122         return await bluetooth.getAvailability();
    123     } catch {
    124         return "unknown";
    125     }
    126 };
    127 
    128 const read_availability_state = async (): Promise<BleAvailabilityState> => {
    129     const availability = read_availability();
    130     let adapter_available: boolean | "unknown" = "unknown";
    131     if (typeof navigator !== "undefined" && has_bluetooth(navigator)) {
    132         adapter_available = await read_adapter_availability(navigator.bluetooth);
    133     }
    134     return { ...availability, adapter_available };
    135 };
    136 
    137 const resolve_request_options = (profile: BleServiceProfile, opts?: BleConnectOptions): BleRequestDeviceOptions => {
    138     const optional_services = [profile.service_uuid];
    139     if (opts?.accept_all_devices) {
    140         return {
    141             acceptAllDevices: true,
    142             optionalServices: optional_services
    143         };
    144     }
    145     const filter: BleRequestDeviceFilter = {
    146         services: [profile.service_uuid]
    147     };
    148     if (opts?.name_prefix) filter.namePrefix = opts.name_prefix;
    149     return {
    150         filters: [filter],
    151         optionalServices: optional_services
    152     };
    153 };
    154 
    155 const map_ble_error = (err: unknown, fallback: BleErrorMessage): BleErrorMessage => {
    156     if (typeof DOMException !== "undefined" && err instanceof DOMException) {
    157         if (err.name === "AbortError") return BLE_ERROR.abort;
    158         if (err.name === "NotAllowedError") return BLE_ERROR.permission_denied;
    159         if (err.name === "NotSupportedError") return BLE_ERROR.unsupported;
    160         if (err.name === "SecurityError") return BLE_ERROR.secure_context_required;
    161         if (err.name === "NotFoundError") return BLE_ERROR.device_not_found;
    162         if (err.name === "NetworkError") return BLE_ERROR.connect_failed;
    163         if (err.name === "InvalidStateError") return BLE_ERROR.connect_failed;
    164         if (err.name === "NotReadableError") return BLE_ERROR.read_failed;
    165     }
    166     return fallback;
    167 };
    168 
    169 const link_abort_signal = (controller: AbortController, signal?: AbortSignal): void => {
    170     if (!signal) return;
    171     if (signal.aborted) {
    172         controller.abort();
    173         return;
    174     }
    175     signal.addEventListener("abort", () => controller.abort(), { once: true });
    176 };
    177 
    178 const wait_with_timeout = async <T>(promise: Promise<T>, timeout_ms?: number): Promise<BleWaitResult<T>> => {
    179     if (!timeout_ms || timeout_ms <= 0) return { value: await promise };
    180     let timeout_id: ReturnType<typeof setTimeout> | undefined;
    181     const timeout_promise: Promise<{ timeout: true }> = new Promise(resolve => {
    182         timeout_id = setTimeout(() => resolve({ timeout: true }), timeout_ms);
    183     });
    184     const guarded = promise.then(value => ({ value }));
    185     const result = await Promise.race([guarded, timeout_promise]);
    186     if (timeout_id) clearTimeout(timeout_id);
    187     if ("timeout" in result) {
    188         void guarded.catch(() => undefined);
    189         return result;
    190     }
    191     return result;
    192 };
    193 
    194 const is_timeout = <T>(value: BleWaitResult<T>): value is { timeout: true } => "timeout" in value;
    195 
    196 const is_error = <T>(value: ResolveErrorMsg<T, BleErrorMessage>): value is { err: BleErrorMessage } => {
    197     return typeof value === "object" && value !== null && "err" in value;
    198 };
    199 
    200 const build_device_info = (device: BleDevice, gatt: BleGattServer): BleDeviceInfo => {
    201     return {
    202         id: device.id,
    203         name: device.name,
    204         connected: gatt.connected
    205     };
    206 };
    207 
    208 class BleSession implements IBleSession {
    209     private active = true;
    210     private on_message?: BleMessageHandler;
    211     private on_disconnect?: BleDisconnectHandler;
    212     private readonly notify_handler: (event: Event) => void;
    213     private readonly disconnect_handler: () => void;
    214 
    215     private constructor(
    216         private readonly device: BleDevice,
    217         private readonly gatt: BleGattServer,
    218         private readonly write_characteristic: BleGattCharacteristic,
    219         private readonly notify_characteristic: BleGattCharacteristic
    220     ) {
    221         this.notify_handler = () => this.handle_notify();
    222         this.disconnect_handler = () => this.stop_with_reason(BLE_ERROR.disconnected, true);
    223         this.device.addEventListener("gattserverdisconnected", this.disconnect_handler);
    224         this.notify_characteristic.addEventListener("characteristicvaluechanged", this.notify_handler);
    225     }
    226 
    227     public static async create(
    228         device: BleDevice,
    229         gatt: BleGattServer,
    230         write_characteristic: BleGattCharacteristic,
    231         notify_characteristic: BleGattCharacteristic,
    232         timeout_ms?: number
    233     ): Promise<ResolveErrorMsg<BleSession, BleErrorMessage>> {
    234         const session = new BleSession(device, gatt, write_characteristic, notify_characteristic);
    235         try {
    236             const result = await wait_with_timeout(notify_characteristic.startNotifications(), timeout_ms);
    237             if (is_timeout(result)) {
    238                 session.stop_with_reason(BLE_ERROR.timeout, false);
    239                 return err_msg(BLE_ERROR.timeout);
    240             }
    241             return session;
    242         } catch (e) {
    243             session.stop_with_reason(BLE_ERROR.notify_failed, false);
    244             return err_msg(map_ble_error(e, BLE_ERROR.notify_failed));
    245         }
    246     }
    247 
    248     public get_active(): boolean {
    249         return this.active;
    250     }
    251 
    252     public get_device_info(): BleDeviceInfo {
    253         return build_device_info(this.device, this.gatt);
    254     }
    255 
    256     public set_on_message(handler?: BleMessageHandler): void {
    257         this.on_message = handler;
    258     }
    259 
    260     public set_on_disconnect(handler?: BleDisconnectHandler): void {
    261         this.on_disconnect = handler;
    262     }
    263 
    264     public async send_message(message: BleMessageInput, opts?: BleWriteOptions): Promise<ResolveErrorMsg<void, BleErrorMessage>> {
    265         if (!this.active) return err_msg(BLE_ERROR.disconnected);
    266         if (!this.gatt.connected) return err_msg(BLE_ERROR.disconnected);
    267 
    268         const resolved = ble_message_bytes(message);
    269         if (is_error(resolved)) return resolved;
    270         const payload = ble_message_buffer_source(resolved);
    271 
    272         const controller = new AbortController();
    273         link_abort_signal(controller, opts?.signal);
    274         if (controller.signal.aborted) return err_msg(BLE_ERROR.abort);
    275 
    276         const write_action = opts?.without_response && this.write_characteristic.writeValueWithoutResponse
    277             ? this.write_characteristic.writeValueWithoutResponse(payload)
    278             : this.write_characteristic.writeValue(payload);
    279 
    280         try {
    281             const result = await wait_with_timeout(write_action, opts?.timeout_ms);
    282             if (is_timeout(result)) return err_msg(BLE_ERROR.timeout);
    283             if (controller.signal.aborted) return err_msg(BLE_ERROR.abort);
    284             return;
    285         } catch (e) {
    286             if (controller.signal.aborted) return err_msg(BLE_ERROR.abort);
    287             return err_msg(map_ble_error(e, BLE_ERROR.write_failed));
    288         }
    289     }
    290 
    291     public async disconnect(): Promise<void> {
    292         this.stop_with_reason(BLE_ERROR.disconnected, true);
    293     }
    294 
    295     private handle_notify(): void {
    296         if (!this.active) return;
    297         const value = this.notify_characteristic.value;
    298         if (!value) return;
    299         const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
    300         if (this.on_message) this.on_message({ bytes, timestamp_ms: Date.now() });
    301     }
    302 
    303     private stop_with_reason(reason: BleErrorMessage, notify_disconnect: boolean): void {
    304         if (!this.active) return;
    305         this.active = false;
    306         this.device.removeEventListener("gattserverdisconnected", this.disconnect_handler);
    307         this.notify_characteristic.removeEventListener("characteristicvaluechanged", this.notify_handler);
    308         if (this.notify_characteristic.stopNotifications) void this.notify_characteristic.stopNotifications();
    309         if (this.gatt.connected) this.gatt.disconnect();
    310         if (notify_disconnect && this.on_disconnect) this.on_disconnect(reason);
    311     }
    312 }
    313 
    314 export interface IWebBle extends IBle { }
    315 
    316 export class WebBle implements IWebBle {
    317     public availability(): BleAvailability {
    318         return read_availability();
    319     }
    320 
    321     public async availability_state(): Promise<BleAvailabilityState> {
    322         return read_availability_state();
    323     }
    324 
    325     public async permission_state(): Promise<BlePermissionState> {
    326         if (typeof navigator === "undefined") return "unknown";
    327         return read_permission_state(navigator);
    328     }
    329 
    330     public async connect(opts?: BleConnectOptions): Promise<ResolveErrorMsg<IBleSession, BleErrorMessage>> {
    331         const availability = read_availability();
    332         if (!availability.window_available) return err_msg(BLE_ERROR.window_undefined);
    333         if (!availability.navigator_available) return err_msg(BLE_ERROR.navigator_undefined);
    334         if (!availability.secure_context) return err_msg(BLE_ERROR.secure_context_required);
    335         if (!availability.bluetooth_available) return err_msg(BLE_ERROR.unsupported);
    336 
    337         if (!has_bluetooth(navigator)) return err_msg(BLE_ERROR.unsupported);
    338         const bluetooth = navigator.bluetooth;
    339         const adapter_available = await read_adapter_availability(bluetooth);
    340         if (adapter_available === false) return err_msg(BLE_ERROR.unavailable);
    341         const permission_state = await read_permission_state(navigator);
    342         if (permission_state === "denied") return err_msg(BLE_ERROR.permission_denied);
    343 
    344         const controller = new AbortController();
    345         link_abort_signal(controller, opts?.signal);
    346         if (controller.signal.aborted) return err_msg(BLE_ERROR.abort);
    347 
    348         const profile = opts?.profile ?? BLE_MESSAGE_PROFILE;
    349         const request_options = resolve_request_options(profile, opts);
    350 
    351         let device: BleDevice;
    352         try {
    353             device = await bluetooth.requestDevice(request_options);
    354         } catch (e) {
    355             return err_msg(map_ble_error(e, BLE_ERROR.connect_failed));
    356         }
    357 
    358         if (controller.signal.aborted) return err_msg(BLE_ERROR.abort);
    359         if (!device.gatt) return err_msg(BLE_ERROR.connect_failed);
    360 
    361         let server: BleGattServer;
    362         try {
    363             const result = await wait_with_timeout(device.gatt.connect(), opts?.timeout_ms);
    364             if (is_timeout(result)) return err_msg(BLE_ERROR.timeout);
    365             server = result.value;
    366         } catch (e) {
    367             return err_msg(map_ble_error(e, BLE_ERROR.connect_failed));
    368         }
    369 
    370         if (controller.signal.aborted) {
    371             server.disconnect();
    372             return err_msg(BLE_ERROR.abort);
    373         }
    374 
    375         let service: BleGattService;
    376         try {
    377             const result = await wait_with_timeout(server.getPrimaryService(profile.service_uuid), opts?.timeout_ms);
    378             if (is_timeout(result)) {
    379                 server.disconnect();
    380                 return err_msg(BLE_ERROR.timeout);
    381             }
    382             service = result.value;
    383         } catch (e) {
    384             server.disconnect();
    385             return err_msg(map_ble_error(e, BLE_ERROR.service_not_found));
    386         }
    387         if (controller.signal.aborted) {
    388             server.disconnect();
    389             return err_msg(BLE_ERROR.abort);
    390         }
    391 
    392         let write_characteristic: BleGattCharacteristic;
    393         try {
    394             const result = await wait_with_timeout(service.getCharacteristic(profile.client_write_uuid), opts?.timeout_ms);
    395             if (is_timeout(result)) {
    396                 server.disconnect();
    397                 return err_msg(BLE_ERROR.timeout);
    398             }
    399             write_characteristic = result.value;
    400         } catch (e) {
    401             server.disconnect();
    402             return err_msg(map_ble_error(e, BLE_ERROR.characteristic_not_found));
    403         }
    404         if (controller.signal.aborted) {
    405             server.disconnect();
    406             return err_msg(BLE_ERROR.abort);
    407         }
    408 
    409         let notify_characteristic: BleGattCharacteristic;
    410         try {
    411             const result = await wait_with_timeout(service.getCharacteristic(profile.client_notify_uuid), opts?.timeout_ms);
    412             if (is_timeout(result)) {
    413                 server.disconnect();
    414                 return err_msg(BLE_ERROR.timeout);
    415             }
    416             notify_characteristic = result.value;
    417         } catch (e) {
    418             server.disconnect();
    419             return err_msg(map_ble_error(e, BLE_ERROR.characteristic_not_found));
    420         }
    421         if (controller.signal.aborted) {
    422             server.disconnect();
    423             return err_msg(BLE_ERROR.abort);
    424         }
    425 
    426         const session = await BleSession.create(
    427             device,
    428             server,
    429             write_characteristic,
    430             notify_characteristic,
    431             opts?.timeout_ms
    432         );
    433         if (is_error(session)) {
    434             server.disconnect();
    435             return session;
    436         }
    437         if (controller.signal.aborted) {
    438             await session.disconnect();
    439             return err_msg(BLE_ERROR.abort);
    440         }
    441         return session;
    442     }
    443 }