misc.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { getUnsafeWindow } from "./dom.js";
  2. import { mapRange } from "./math.js";
  3. import type { Stringifiable } from "./types.js";
  4. /**
  5. * Automatically appends an `s` to the passed {@linkcode word}, if {@linkcode num} is not equal to 1
  6. * @param word A word in singular form, to auto-convert to plural
  7. * @param num If this is an array or NodeList, the amount of items is used
  8. */
  9. export function autoPlural(word: Stringifiable, num: number | unknown[] | NodeList): string {
  10. if(Array.isArray(num) || num instanceof NodeList)
  11. num = num.length;
  12. return `${word}${num === 1 ? "" : "s"}`;
  13. }
  14. /** Pauses async execution for the specified time in ms */
  15. export function pauseFor(time: number): Promise<void> {
  16. return new Promise<void>((res) => {
  17. setTimeout(() => res(), time);
  18. });
  19. }
  20. /**
  21. * Calls the passed {@linkcode func} after the specified {@linkcode timeout} in ms (defaults to 300).
  22. * Any subsequent calls to this function will reset the timer and discard all previous calls.
  23. * @param func The function to call after the timeout
  24. * @param timeout The time in ms to wait before calling the function
  25. * @param edge Whether to call the function at the very first call ("rising" edge) or the very last call ("falling" edge, default)
  26. */
  27. export function debounce<
  28. TFunc extends (...args: TArgs[]) => void, // eslint-disable-next-line @typescript-eslint/no-explicit-any
  29. TArgs = any,
  30. > (
  31. func: TFunc,
  32. timeout = 300,
  33. edge: "rising" | "falling" = "falling"
  34. ): (...args: TArgs[]) => void {
  35. let timer: NodeJS.Timeout | undefined;
  36. return function(...args: TArgs[]) {
  37. if(edge === "rising") {
  38. if(!timer) {
  39. func.apply(this, args);
  40. timer = setTimeout(() => timer = undefined, timeout);
  41. }
  42. }
  43. else {
  44. clearTimeout(timer);
  45. timer = setTimeout(() => func.apply(this, args), timeout);
  46. }
  47. };
  48. }
  49. /** Options for the `fetchAdvanced()` function */
  50. export type FetchAdvancedOpts = Omit<
  51. RequestInit & Partial<{
  52. /** Timeout in milliseconds after which the fetch call will be canceled with an AbortController signal */
  53. timeout: number;
  54. }>,
  55. "signal"
  56. >;
  57. /** Calls the fetch API with special options like a timeout */
  58. export async function fetchAdvanced(input: RequestInfo | URL, options: FetchAdvancedOpts = {}): Promise<Response> {
  59. const { timeout = 10000 } = options;
  60. let signalOpts: Partial<RequestInit> = {},
  61. id: NodeJS.Timeout | undefined = undefined;
  62. if(timeout >= 0) {
  63. const controller = new AbortController();
  64. id = setTimeout(() => controller.abort(), timeout);
  65. signalOpts = { signal: controller.signal };
  66. }
  67. const res = await fetch(input, {
  68. ...options,
  69. ...signalOpts,
  70. });
  71. clearTimeout(id);
  72. return res;
  73. }
  74. /**
  75. * Inserts the passed values into a string at the respective placeholders.
  76. * The placeholder format is `%n`, where `n` is the 1-indexed argument number.
  77. * @param input The string to insert the values into
  78. * @param values The values to insert, in order, starting at `%1`
  79. */
  80. export function insertValues(input: string, ...values: Stringifiable[]): string {
  81. return input.replace(/%\d/gm, (match) => {
  82. const argIndex = Number(match.substring(1)) - 1;
  83. return (values[argIndex] ?? match)?.toString();
  84. });
  85. }
  86. /** Compresses a string or an ArrayBuffer using the provided {@linkcode compressionFormat} and returns it as a base64 string */
  87. export async function compress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType?: "string"): Promise<string>
  88. /** Compresses a string or an ArrayBuffer using the provided {@linkcode compressionFormat} and returns it as an ArrayBuffer */
  89. export async function compress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "arrayBuffer"): Promise<ArrayBuffer>
  90. /** Compresses a string or an ArrayBuffer using the provided {@linkcode compressionFormat} and returns it as a base64 string or ArrayBuffer, depending on what {@linkcode outputType} is set to */
  91. export async function compress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "string" | "arrayBuffer" = "string"): Promise<ArrayBuffer | string> {
  92. const byteArray = typeof input === "string" ? new TextEncoder().encode(input) : input;
  93. const comp = new CompressionStream(compressionFormat);
  94. const writer = comp.writable.getWriter();
  95. writer.write(byteArray);
  96. writer.close();
  97. const buf = await (new Response(comp.readable).arrayBuffer());
  98. return outputType === "arrayBuffer" ? buf : ab2str(buf);
  99. }
  100. /** Decompresses a previously compressed base64 string or ArrayBuffer, with the format passed by {@linkcode compressionFormat}, converted to a string */
  101. export async function decompress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType?: "string"): Promise<string>
  102. /** Decompresses a previously compressed base64 string or ArrayBuffer, with the format passed by {@linkcode compressionFormat}, converted to an ArrayBuffer */
  103. export async function decompress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "arrayBuffer"): Promise<ArrayBuffer>
  104. /** Decompresses a previously compressed base64 string or ArrayBuffer, with the format passed by {@linkcode compressionFormat}, converted to a string or ArrayBuffer, depending on what {@linkcode outputType} is set to */
  105. export async function decompress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "string" | "arrayBuffer" = "string"): Promise<ArrayBuffer | string> {
  106. const byteArray = typeof input === "string" ? str2ab(input) : input;
  107. const decomp = new DecompressionStream(compressionFormat);
  108. const writer = decomp.writable.getWriter();
  109. writer.write(byteArray);
  110. writer.close();
  111. const buf = await (new Response(decomp.readable).arrayBuffer());
  112. return outputType === "arrayBuffer" ? buf : new TextDecoder().decode(buf);
  113. }
  114. /** Converts an ArrayBuffer to a base64-encoded string */
  115. function ab2str(buf: ArrayBuffer) {
  116. return getUnsafeWindow().btoa(
  117. new Uint8Array(buf)
  118. .reduce((data, byte) => data + String.fromCharCode(byte), "")
  119. );
  120. }
  121. /** Converts a base64-encoded string to an ArrayBuffer representation of its bytes */
  122. function str2ab(str: string) {
  123. return Uint8Array.from(getUnsafeWindow().atob(str), c => c.charCodeAt(0));
  124. }
  125. /**
  126. * Creates a hash / checksum of the given {@linkcode input} string or ArrayBuffer using the specified {@linkcode algorithm} ("SHA-256" by default).
  127. *
  128. * ⚠️ Uses the SubtleCrypto API so it needs to run in a secure context (HTTPS).
  129. * ⚠️ If you use this for cryptography, make sure to use a secure algorithm (under no circumstances use SHA-1) and to [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) your input data.
  130. */
  131. export async function computeHash(input: string | ArrayBuffer, algorithm = "SHA-256") {
  132. let data: ArrayBuffer;
  133. if(typeof input === "string") {
  134. const encoder = new TextEncoder();
  135. data = encoder.encode(input);
  136. }
  137. else
  138. data = input;
  139. const hashBuffer = await crypto.subtle.digest(algorithm, data);
  140. const hashArray = Array.from(new Uint8Array(hashBuffer));
  141. const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, "0")).join("");
  142. return hashHex;
  143. }
  144. /**
  145. * Generates a random ID with the specified length and radix (16 characters and hexadecimal by default)
  146. *
  147. * ⚠️ Not suitable for generating anything related to cryptography! Use [SubtleCrypto's `generateKey()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey) for that instead.
  148. * @param length The length of the ID to generate (defaults to 16)
  149. * @param radix The [radix](https://en.wikipedia.org/wiki/Radix) of each digit (defaults to 16 which is hexadecimal. Use 2 for binary, 10 for decimal, 36 for alphanumeric, etc.)
  150. * @param enhancedEntropy If set to true, uses [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) for better cryptographic randomness (this also makes it take MUCH longer to generate)
  151. */
  152. export function randomId(length = 16, radix = 16, enhancedEntropy = false): string {
  153. if(enhancedEntropy) {
  154. const arr = new Uint8Array(length);
  155. crypto.getRandomValues(arr);
  156. return Array.from(
  157. arr,
  158. (v) => mapRange(v, 0, 255, 0, radix).toString(radix).substring(0, 1),
  159. ).join("");
  160. }
  161. return Array.from(
  162. { length },
  163. () => Math.floor(Math.random() * radix).toString(radix),
  164. ).join("");
  165. }