web_lib

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

web.ts (12243B)


      1 import { err_msg, type ResolveErrorMsg } from "@radroots/utils";
      2 import { NFC_ERROR, type NfcErrorMessage } from "./error.js";
      3 import { nfc_message_to_web, nfc_read_payload_from_event } from "./records.js";
      4 import type {
      5     INfc,
      6     INfcScanSession,
      7     NdefReader,
      8     NdefWriteOptions,
      9     NfcAvailability,
     10     NfcPermissionState,
     11     NfcRecordInput,
     12     NfcReadPayload,
     13     NfcReadHandler,
     14     NfcErrorHandler,
     15     NfcScanOptions,
     16     NfcWriteInput,
     17     NfcWriteOptions
     18 } from "./types.js";
     19 
     20 type NfcStopReason = "user" | "abort" | "timeout" | "error";
     21 
     22 type NfcPermissionName = PermissionName | "nfc";
     23 
     24 type NfcPermissionDescriptor = {
     25     name: NfcPermissionName;
     26 };
     27 
     28 interface PermissionsNfc {
     29     query(permissionDesc: NfcPermissionDescriptor): Promise<PermissionStatus>;
     30 }
     31 
     32 interface NavigatorWithPermissions extends Navigator {
     33     permissions: PermissionsNfc;
     34 }
     35 
     36 const has_permissions_api = (nav: Navigator): nav is NavigatorWithPermissions => "permissions" in nav;
     37 
     38 const read_permission_state = async (nav: Navigator): Promise<NfcPermissionState> => {
     39     if (!has_permissions_api(nav)) return "unknown";
     40     try {
     41         const status = await nav.permissions.query({ name: "nfc" });
     42         return status.state;
     43     } catch {
     44         return "unknown";
     45     }
     46 };
     47 
     48 const read_availability = (): NfcAvailability => {
     49     const window_available = typeof window !== "undefined";
     50     const secure_context = window_available && window.isSecureContext === true;
     51     const reader_available = window_available && typeof window.NDEFReader !== "undefined";
     52     return {
     53         supported: window_available && secure_context && reader_available,
     54         secure_context,
     55         window_available,
     56         reader_available
     57     };
     58 };
     59 
     60 const create_reader = (): ResolveErrorMsg<NdefReader, NfcErrorMessage> => {
     61     const availability = read_availability();
     62     if (!availability.window_available) return err_msg(NFC_ERROR.window_undefined);
     63     if (!availability.secure_context) return err_msg(NFC_ERROR.secure_context_required);
     64     if (!availability.reader_available) return err_msg(NFC_ERROR.unsupported);
     65     const Reader = window.NDEFReader;
     66     if (!Reader) return err_msg(NFC_ERROR.unsupported);
     67     return new Reader();
     68 };
     69 
     70 const map_nfc_error = (err: unknown, fallback: NfcErrorMessage): NfcErrorMessage => {
     71     if (typeof DOMException !== "undefined" && err instanceof DOMException) {
     72         if (err.name === "AbortError") return NFC_ERROR.abort;
     73         if (err.name === "NotAllowedError") return NFC_ERROR.permission_denied;
     74         if (err.name === "NotSupportedError") return NFC_ERROR.unsupported;
     75         if (err.name === "SecurityError") return NFC_ERROR.secure_context_required;
     76         if (err.name === "NotReadableError") return NFC_ERROR.read_failed;
     77     }
     78     return fallback;
     79 };
     80 
     81 const link_abort_signal = (controller: AbortController, signal?: AbortSignal): void => {
     82     if (!signal) return;
     83     if (signal.aborted) {
     84         controller.abort();
     85         return;
     86     }
     87     signal.addEventListener("abort", () => controller.abort(), { once: true });
     88 };
     89 
     90 const create_timeout = (controller: AbortController, timeout_ms?: number): { is_timeout: () => boolean; clear: () => void } => {
     91     let fired = false;
     92     let timeout_id: ReturnType<typeof setTimeout> | undefined;
     93     if (timeout_ms && timeout_ms > 0) {
     94         timeout_id = setTimeout(() => {
     95             fired = true;
     96             controller.abort();
     97         }, timeout_ms);
     98     }
     99     const clear = (): void => {
    100         if (timeout_id) clearTimeout(timeout_id);
    101         timeout_id = undefined;
    102     };
    103     const is_timeout = (): boolean => fired;
    104     return { is_timeout, clear };
    105 };
    106 
    107 const build_write_options = (controller: AbortController, opts?: NfcWriteOptions): NdefWriteOptions => {
    108     return {
    109         signal: controller.signal,
    110         overwrite: opts?.overwrite
    111     };
    112 };
    113 
    114 const is_message_input = (value: NfcWriteInput): value is { records: NfcRecordInput[] } => {
    115     if (typeof value !== "object" || value === null) return false;
    116     if (!("records" in value)) return false;
    117     return Array.isArray(value.records);
    118 };
    119 
    120 const resolve_message = (value: NfcWriteInput): ResolveErrorMsg<string | ReturnType<typeof nfc_message_to_web>, NfcErrorMessage> => {
    121     if (typeof value === "string") return value;
    122     if (Array.isArray(value)) {
    123         if (!value.length) return err_msg(NFC_ERROR.invalid_message);
    124         return nfc_message_to_web({ records: value });
    125     }
    126     if (is_message_input(value)) {
    127         if (!value.records.length) return err_msg(NFC_ERROR.invalid_message);
    128         return nfc_message_to_web(value);
    129     }
    130     if (typeof value === "object" && value !== null && "records" in value) return err_msg(NFC_ERROR.invalid_message);
    131     return nfc_message_to_web({ records: [value] });
    132 };
    133 
    134 const is_error = <T>(value: ResolveErrorMsg<T, NfcErrorMessage>): value is { err: NfcErrorMessage } => {
    135     return typeof value === "object" && value !== null && "err" in value;
    136 };
    137 
    138 class NfcScanSession implements INfcScanSession {
    139     private active = true;
    140     private started = false;
    141     private stop_reason: NfcStopReason | null = null;
    142     private on_read?: NfcReadHandler;
    143     private on_error?: NfcErrorHandler;
    144     private timeout_id?: ReturnType<typeof setTimeout>;
    145 
    146     constructor(
    147         private readonly reader: NdefReader,
    148         private readonly controller: AbortController,
    149         opts: {
    150             on_read?: NfcReadHandler;
    151             on_error?: NfcErrorHandler;
    152             timeout_ms?: number;
    153             on_stop: () => void;
    154         }
    155     ) {
    156         this.on_read = opts.on_read;
    157         this.on_error = opts.on_error;
    158         if (opts.timeout_ms && opts.timeout_ms > 0) {
    159             this.timeout_id = setTimeout(() => this.stop_with_reason("timeout"), opts.timeout_ms);
    160         }
    161         this.controller.signal.addEventListener("abort", () => {
    162             if (this.stop_reason) return;
    163             this.stop_with_reason("abort");
    164         }, { once: true });
    165         this.on_stop = opts.on_stop;
    166     }
    167 
    168     private on_stop: () => void;
    169 
    170     public get_active(): boolean {
    171         return this.active;
    172     }
    173 
    174     public get_signal(): AbortSignal {
    175         return this.controller.signal;
    176     }
    177 
    178     public set_on_read(handler?: NfcReadHandler): void {
    179         this.on_read = handler;
    180     }
    181 
    182     public set_on_error(handler?: NfcErrorHandler): void {
    183         this.on_error = handler;
    184     }
    185 
    186     public async start(): Promise<ResolveErrorMsg<void, NfcErrorMessage>> {
    187         if (this.started) return err_msg(NFC_ERROR.scan_failed);
    188         this.started = true;
    189         try {
    190             this.reader.onreading = event => {
    191                 if (!this.active) return;
    192                 const payload = nfc_read_payload_from_event(event);
    193                 if (this.on_read) this.on_read(payload);
    194             };
    195             this.reader.onreadingerror = () => {
    196                 if (!this.active) return;
    197                 if (this.on_error) this.on_error(NFC_ERROR.read_failed);
    198             };
    199             await this.reader.scan({ signal: this.controller.signal });
    200             return;
    201         } catch (e) {
    202             const mapped = map_nfc_error(e, NFC_ERROR.scan_failed);
    203             this.stop_with_reason("error", mapped);
    204             return err_msg(mapped);
    205         }
    206     }
    207 
    208     public async stop(): Promise<void> {
    209         this.stop_with_reason("user");
    210     }
    211 
    212     private stop_with_reason(reason: NfcStopReason, error?: NfcErrorMessage): void {
    213         if (!this.active) return;
    214         this.active = false;
    215         this.stop_reason = reason;
    216         if (this.timeout_id) {
    217             clearTimeout(this.timeout_id);
    218             this.timeout_id = undefined;
    219         }
    220         this.reader.onreading = null;
    221         this.reader.onreadingerror = null;
    222         if (!this.controller.signal.aborted) this.controller.abort();
    223         this.on_stop();
    224         if (reason === "abort" && this.on_error) this.on_error(NFC_ERROR.abort);
    225         if (reason === "timeout" && this.on_error) this.on_error(NFC_ERROR.timeout);
    226         if (reason === "error" && error && this.on_error) this.on_error(error);
    227     }
    228 }
    229 
    230 export interface IWebNfc extends INfc {}
    231 
    232 export class WebNfc implements IWebNfc {
    233     private scan_session: NfcScanSession | null = null;
    234 
    235     public availability(): NfcAvailability {
    236         return read_availability();
    237     }
    238 
    239     public async permission_state(): Promise<NfcPermissionState> {
    240         if (typeof navigator === "undefined") return "unknown";
    241         return read_permission_state(navigator);
    242     }
    243 
    244     public async scan_start(opts?: NfcScanOptions): Promise<ResolveErrorMsg<INfcScanSession, NfcErrorMessage>> {
    245         if (this.scan_session && this.scan_session.get_active()) return err_msg(NFC_ERROR.scan_in_progress);
    246         const reader = create_reader();
    247         if (is_error(reader)) return reader;
    248 
    249         const controller = new AbortController();
    250         link_abort_signal(controller, opts?.signal);
    251         if (controller.signal.aborted) return err_msg(NFC_ERROR.abort);
    252 
    253         const session = new NfcScanSession(reader, controller, {
    254             on_read: opts?.on_read,
    255             on_error: opts?.on_error,
    256             timeout_ms: opts?.timeout_ms,
    257             on_stop: () => {
    258                 if (this.scan_session === session) this.scan_session = null;
    259             }
    260         });
    261 
    262         const started = await session.start();
    263         if (is_error(started)) return started;
    264         this.scan_session = session;
    265         return session;
    266     }
    267 
    268     public async scan_once(opts?: NfcScanOptions): Promise<ResolveErrorMsg<NfcReadPayload, NfcErrorMessage>> {
    269         const session = await this.scan_start({
    270             signal: opts?.signal,
    271             timeout_ms: opts?.timeout_ms
    272         });
    273         if (is_error(session)) return session;
    274 
    275         return await new Promise<ResolveErrorMsg<NfcReadPayload, NfcErrorMessage>>(resolve => {
    276             session.set_on_read(payload => {
    277                 session.stop();
    278                 if (opts?.on_read) opts.on_read(payload);
    279                 resolve(payload);
    280             });
    281             session.set_on_error(error => {
    282                 session.stop();
    283                 if (opts?.on_error) opts.on_error(error);
    284                 resolve(err_msg(error));
    285             });
    286         });
    287     }
    288 
    289     public async write(message: NfcWriteInput, opts?: NfcWriteOptions): Promise<ResolveErrorMsg<void, NfcErrorMessage>> {
    290         const reader = create_reader();
    291         if (is_error(reader)) return reader;
    292 
    293         const resolved = resolve_message(message);
    294         if (is_error(resolved)) return resolved;
    295 
    296         const controller = new AbortController();
    297         link_abort_signal(controller, opts?.signal);
    298 
    299         if (controller.signal.aborted) {
    300             return err_msg(NFC_ERROR.abort);
    301         }
    302 
    303         const timeout = create_timeout(controller, opts?.timeout_ms);
    304 
    305         try {
    306             await reader.write(resolved, build_write_options(controller, opts));
    307             timeout.clear();
    308             return;
    309         } catch (e) {
    310             if (timeout.is_timeout()) {
    311                 timeout.clear();
    312                 return err_msg(NFC_ERROR.timeout);
    313             }
    314             timeout.clear();
    315             return err_msg(map_nfc_error(e, NFC_ERROR.write_failed));
    316         }
    317     }
    318 
    319     public async make_read_only(opts?: NfcWriteOptions): Promise<ResolveErrorMsg<void, NfcErrorMessage>> {
    320         const reader = create_reader();
    321         if (is_error(reader)) return reader;
    322         if (!reader.makeReadOnly) return err_msg(NFC_ERROR.unsupported);
    323 
    324         const controller = new AbortController();
    325         link_abort_signal(controller, opts?.signal);
    326 
    327         if (controller.signal.aborted) {
    328             return err_msg(NFC_ERROR.abort);
    329         }
    330 
    331         const timeout = create_timeout(controller, opts?.timeout_ms);
    332 
    333         try {
    334             await reader.makeReadOnly(build_write_options(controller, opts));
    335             timeout.clear();
    336             return;
    337         } catch (e) {
    338             if (timeout.is_timeout()) {
    339                 timeout.clear();
    340                 return err_msg(NFC_ERROR.timeout);
    341             }
    342             timeout.clear();
    343             return err_msg(map_nfc_error(e, NFC_ERROR.make_read_only_failed));
    344         }
    345     }
    346 }