zilla-util 1.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE.txt +202 -0
  2. package/lib/esm/array.d.ts +30 -0
  3. package/lib/esm/array.js +111 -0
  4. package/lib/esm/array.js.map +1 -0
  5. package/lib/esm/base64.d.ts +2 -0
  6. package/lib/esm/base64.js +51 -0
  7. package/lib/esm/base64.js.map +1 -0
  8. package/lib/esm/crypto.d.ts +16 -0
  9. package/lib/esm/crypto.js +182 -0
  10. package/lib/esm/crypto.js.map +1 -0
  11. package/lib/esm/date.d.ts +2 -0
  12. package/lib/esm/date.js +47 -0
  13. package/lib/esm/date.js.map +1 -0
  14. package/lib/esm/deep.d.ts +24 -0
  15. package/lib/esm/deep.js +318 -0
  16. package/lib/esm/deep.js.map +1 -0
  17. package/lib/esm/error.d.ts +1 -0
  18. package/lib/esm/error.js +9 -0
  19. package/lib/esm/error.js.map +1 -0
  20. package/lib/esm/hash.d.ts +4 -0
  21. package/lib/esm/hash.js +18 -0
  22. package/lib/esm/hash.js.map +1 -0
  23. package/lib/esm/index.d.ts +17 -0
  24. package/lib/esm/index.js +18 -0
  25. package/lib/esm/index.js.map +1 -0
  26. package/lib/esm/logger.d.ts +18 -0
  27. package/lib/esm/logger.js +18 -0
  28. package/lib/esm/logger.js.map +1 -0
  29. package/lib/esm/lru.d.ts +25 -0
  30. package/lib/esm/lru.js +137 -0
  31. package/lib/esm/lru.js.map +1 -0
  32. package/lib/esm/msg.d.ts +29 -0
  33. package/lib/esm/msg.js +7 -0
  34. package/lib/esm/msg.js.map +1 -0
  35. package/lib/esm/object.d.ts +8 -0
  36. package/lib/esm/object.js +31 -0
  37. package/lib/esm/object.js.map +1 -0
  38. package/lib/esm/package.d.ts +2 -0
  39. package/lib/esm/package.js +38 -0
  40. package/lib/esm/package.js.map +1 -0
  41. package/lib/esm/regex.d.ts +1 -0
  42. package/lib/esm/regex.js +22 -0
  43. package/lib/esm/regex.js.map +1 -0
  44. package/lib/esm/retry.d.ts +13 -0
  45. package/lib/esm/retry.js +57 -0
  46. package/lib/esm/retry.js.map +1 -0
  47. package/lib/esm/sha256.d.ts +50 -0
  48. package/lib/esm/sha256.js +181 -0
  49. package/lib/esm/sha256.js.map +1 -0
  50. package/lib/esm/string.d.ts +106 -0
  51. package/lib/esm/string.js +257 -0
  52. package/lib/esm/string.js.map +1 -0
  53. package/lib/esm/subst.d.ts +6 -0
  54. package/lib/esm/subst.js +37 -0
  55. package/lib/esm/subst.js.map +1 -0
  56. package/lib/esm/time.d.ts +27 -0
  57. package/lib/esm/time.js +84 -0
  58. package/lib/esm/time.js.map +1 -0
  59. package/lib/esm/url.d.ts +9 -0
  60. package/lib/esm/url.js +80 -0
  61. package/lib/esm/url.js.map +1 -0
  62. package/package.json +72 -0
  63. package/src/array.ts +134 -0
  64. package/src/base64.ts +53 -0
  65. package/src/crypto.ts +281 -0
  66. package/src/date.ts +48 -0
  67. package/src/deep.ts +348 -0
  68. package/src/error.ts +7 -0
  69. package/src/hash.ts +20 -0
  70. package/src/index.ts +17 -0
  71. package/src/logger.ts +35 -0
  72. package/src/lru.ts +187 -0
  73. package/src/msg.ts +33 -0
  74. package/src/object.ts +43 -0
  75. package/src/package.ts +39 -0
  76. package/src/retry.ts +71 -0
  77. package/src/string.ts +338 -0
  78. package/src/subst.ts +41 -0
  79. package/src/time.ts +105 -0
  80. package/src/url.ts +92 -0
package/src/package.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { GenericLogger } from "./logger.js";
2
+
3
+ const packageJsonUrl = async (startUrl: URL): Promise<URL | null> => {
4
+ let currentUrl = new URL(startUrl);
5
+
6
+ while (true) {
7
+ const packageUrl = new URL("package.json", currentUrl);
8
+ try {
9
+ const packageJson = await import(packageUrl.toString(), { assert: { type: "json" } });
10
+ if (packageJson) return packageUrl; // Found package.json
11
+ } catch {
12
+ const parentUrl = new URL("..", currentUrl);
13
+ if (parentUrl.toString() === currentUrl.toString()) break; // Stop if we reach the root
14
+ currentUrl = parentUrl; // Move up one level
15
+ }
16
+ }
17
+ return null; // Not found
18
+ };
19
+
20
+ export const packageVersion = async (
21
+ importMetaUrl: string = import.meta.url,
22
+ logger?: GenericLogger
23
+ ): Promise<string | undefined> => {
24
+ try {
25
+ const packageUrl = await packageJsonUrl(new URL(importMetaUrl));
26
+ if (!packageUrl) throw new Error("package.json not found");
27
+
28
+ const packageJson = await import(packageUrl.toString(), { assert: { type: "json" } });
29
+ return packageJson.version;
30
+ } catch (e) {
31
+ const msg = `packageVersion importMetaUrl=${importMetaUrl} error determining package version: error=${e}`;
32
+ if (logger) {
33
+ logger.error(msg, e);
34
+ } else {
35
+ console.error(msg);
36
+ }
37
+ return undefined;
38
+ }
39
+ };
package/src/retry.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { sleep } from "./time.js";
2
+ import { GenericLogger } from "./logger.js";
3
+ import {safeStringify} from "./string.js";
4
+
5
+ export type RetryOptions = {
6
+ maxAttempts?: number;
7
+ backoffBaseMillis?: number;
8
+ backoffMultiplier?: number;
9
+ canRetry?: (e: Error) => Promise<boolean> | boolean;
10
+ };
11
+
12
+ export const DEFAULT_RETRY_MAX_ATTEMPTS = 3;
13
+ export const DEFAULT_RETRY_BACKOFF_BASE_MILLIS = 250;
14
+ export const DEFAULT_RETRY_BACKOFF_MULTIPLIER = 3;
15
+ export const DEFAULT_RETRY_CAN_RETRY_FUNC = () => true;
16
+
17
+ export const DEFAULT_RETRY_OPTS: RetryOptions = {
18
+ maxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
19
+ backoffBaseMillis: DEFAULT_RETRY_BACKOFF_BASE_MILLIS,
20
+ backoffMultiplier: DEFAULT_RETRY_BACKOFF_MULTIPLIER,
21
+ canRetry: DEFAULT_RETRY_CAN_RETRY_FUNC,
22
+ };
23
+
24
+ export const retry = async <T>(
25
+ fn: () => Promise<T>,
26
+ opts?: RetryOptions,
27
+ logger?: GenericLogger,
28
+ action?: string
29
+ ): Promise<T> => {
30
+ const maxAttempts = opts?.maxAttempts || DEFAULT_RETRY_MAX_ATTEMPTS;
31
+ const backoffBaseMillis = opts?.backoffBaseMillis || DEFAULT_RETRY_BACKOFF_BASE_MILLIS;
32
+ const backoffMultiplier = opts?.backoffMultiplier || DEFAULT_RETRY_BACKOFF_MULTIPLIER;
33
+ const canRetry = opts?.canRetry || DEFAULT_RETRY_CAN_RETRY_FUNC;
34
+ const prefix = logger ? (action ? `@@@ retry[${action}]:` : "retry:") : "";
35
+ let backoffTime = backoffBaseMillis;
36
+ let error;
37
+ if (logger && logger.isDebugEnabled()) {
38
+ logger.debug(`${prefix} starting with opts=${opts ? safeStringify(opts) : "undefined"}`);
39
+ }
40
+ for (let i = 0; i < maxAttempts; i++) {
41
+ const iPrefix = `${prefix} [${i + 1}/${maxAttempts}]:`;
42
+ if (i > 0) {
43
+ if (logger && logger.isDebugEnabled()) {
44
+ logger.debug(`${iPrefix} waiting for ${backoffTime} before retrying`);
45
+ }
46
+ await sleep(backoffTime);
47
+ backoffTime = Math.floor(backoffTime * backoffMultiplier);
48
+ }
49
+ try {
50
+ if (logger && logger.isDebugEnabled()) {
51
+ logger.debug(`${iPrefix} calling function`);
52
+ }
53
+ return await fn();
54
+ } catch (e) {
55
+ error = e;
56
+ if (!(await canRetry(e as Error))) {
57
+ if (logger && logger.isDebugEnabled()) {
58
+ logger.debug(`${iPrefix} function threw error and canRetry=false, throwing e=${e}`, e as Error);
59
+ }
60
+ throw e;
61
+ }
62
+ if (logger && logger.isDebugEnabled()) {
63
+ logger.debug(`${iPrefix} function threw error=${e} and canRetry=true, continuing`, e as Error);
64
+ }
65
+ }
66
+ }
67
+ if (logger && logger.isDebugEnabled()) {
68
+ logger.debug(`${prefix} maxAttempts exceeded, throwing most recent error=${error}`, error as Error);
69
+ }
70
+ throw error;
71
+ };
package/src/string.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { ZillaClock } from "./time.js";
2
+ import { GenericLogger } from "./logger.js";
3
+ import GraphemeSplitter from "grapheme-splitter";
4
+
5
+ export const capitalize = (s?: string): string =>
6
+ s && s.length > 0 ? s.substring(0, 1).toUpperCase() + s.substring(1) : "";
7
+
8
+ export const uncapitalize = (s?: string): string =>
9
+ s && s.length > 0 ? s.substring(0, 1).toLowerCase() + s.substring(1) : "";
10
+
11
+ export const basefilename = (s: string): string => {
12
+ if (!s) return s; // return null/undefined as-is
13
+
14
+ // drop trailing slashes
15
+ while (s.endsWith("/")) {
16
+ s = s.substring(0, s.length - 1);
17
+ }
18
+ const i = s ? s.lastIndexOf("/") : -1;
19
+ return i === -1 ? s : s.substring(i + 1);
20
+ };
21
+
22
+ export const ext = (s: string): string => {
23
+ if (!s) return s; // return null/undefined/no-dot files as-is
24
+ const dot = s.lastIndexOf(".");
25
+ return dot === -1 || dot === s.length - 1 ? "" : s.substring(dot + 1);
26
+ };
27
+
28
+ export const basefilenameWithoutExt = (s: string): string => {
29
+ s = basefilename(s);
30
+ if (!s) return s; // return null/undefined as-is
31
+
32
+ // drop trailing dots
33
+ while (s.endsWith(".")) {
34
+ s = s.substring(0, s.length - 1);
35
+ }
36
+ const i = s ? s.lastIndexOf(".") : -1;
37
+ return i === -1 ? s : s.substring(0, i);
38
+ };
39
+
40
+ export const dateAsYYYYMMDD = (date: Date): string =>
41
+ `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date
42
+ .getDate()
43
+ .toString()
44
+ .padStart(2, "0")}`;
45
+
46
+ export const dateAsUTCYYYYMMDD = (date: Date): string =>
47
+ `${date.getUTCFullYear()}-${(date.getUTCMonth() + 1).toString().padStart(2, "0")}-${date
48
+ .getUTCDate()
49
+ .toString()
50
+ .padStart(2, "0")}`;
51
+
52
+ export const nextDateAsYYYYMMDD = (d: string): string => {
53
+ const dt = new Date(`${d}T00:00:00Z`); // UTC midnight of current day
54
+ dt.setUTCDate(dt.getUTCDate() + 1); // +1 calendar day
55
+ return dt.toISOString().slice(0, 10); // back to YYYY‑MM‑DD
56
+ };
57
+
58
+ export const dateAsYYYYMMDDHHmmSS = (date: Date): string =>
59
+ `${date.getFullYear()}${(date.getMonth() + 1).toString().padStart(2, "0")}${date
60
+ .getDate()
61
+ .toString()
62
+ .padStart(2, "0")}-${("" + date.getHours()).padStart(2, "0")}${("" + date.getMinutes()).padStart(2, "0")}${(
63
+ "" + date.getSeconds()
64
+ ).padStart(2, "0")}`;
65
+
66
+ export const timestampAsYYYYMMDD = (timestamp: number): string => dateAsYYYYMMDD(new Date(timestamp));
67
+ export const timestampAsUTCYYYYMMDD = (timestamp: number): string => dateAsUTCYYYYMMDD(new Date(timestamp));
68
+ export const timestampAsYYYYMMDDHHmmSS = (timestamp: number): string => dateAsYYYYMMDDHHmmSS(new Date(timestamp));
69
+
70
+ export const nowAsYYYYMMDD = (): string => timestampAsYYYYMMDD(Date.now());
71
+ export const nowAsYYYYMMDDHHmmSS = (): string => timestampAsYYYYMMDDHHmmSS(Date.now());
72
+
73
+ export const sluggize = (name: string, replaceChar = "_"): string => {
74
+ replaceChar ||= "_";
75
+ return name
76
+ .trim()
77
+ .replace(new RegExp(`[^A-Z\\d${replaceChar}]+`, "gi"), replaceChar)
78
+ .replace(/[\s_-]+/g, replaceChar)
79
+ .replace(new RegExp(`[${replaceChar}]+`, "gi"), replaceChar)
80
+ .replace(new RegExp(`^[${replaceChar}]+`, "gi"), "")
81
+ .replace(new RegExp(`[${replaceChar}]+$`, "gi"), "")
82
+ .toLowerCase();
83
+ };
84
+
85
+ export const hyphenate = (name: string): string => sluggize(name, "-");
86
+
87
+ const generateRandomValues = (count: number): Uint8Array => {
88
+ if (globalThis && globalThis.crypto && globalThis.crypto.getRandomValues) {
89
+ return globalThis.crypto.getRandomValues(new Uint8Array(count));
90
+ } else {
91
+ const arr = new Uint8Array(count);
92
+ for (let i = 0; i < count; i++) {
93
+ arr[i] = (Math.random() * 256) | 0;
94
+ }
95
+ return arr;
96
+ }
97
+ };
98
+
99
+ export const uuidv4 = (squeezed?: boolean): string => {
100
+ const arr = generateRandomValues(16);
101
+
102
+ // Per 4.4, set bits for version and clock_seq_hi_and_reserved
103
+ arr[6] = (arr[6] & 0x0f) | 0x40;
104
+ arr[8] = (arr[8] & 0x3f) | 0x80;
105
+
106
+ // Join it all together
107
+ const pad = (n: string) => (n.length < 2 ? "0" + n : n);
108
+ const uuid = Array.from(arr)
109
+ .map((b) => pad(b.toString(16)))
110
+ .join("");
111
+
112
+ return squeezed
113
+ ? uuid
114
+ : `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20)}`;
115
+ };
116
+
117
+ export const camel2kebab = (camelCaseString: string): string =>
118
+ camelCaseString.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, "$1-$2").toLowerCase();
119
+
120
+ export const camel2snake = (camelCaseString: string): string =>
121
+ camelCaseString.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, "$1_$2").toLowerCase();
122
+
123
+ export const kebab2camel = (kebabCaseString: string): string =>
124
+ kebabCaseString
125
+ .toLowerCase()
126
+ .replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace("-", "").replace("_", ""));
127
+
128
+ export const snake2camel = kebab2camel;
129
+
130
+ export const hasUpperCase = (str: string): boolean => /[A-Z]/.test(str);
131
+
132
+ export const startsWithPrefix = (s: string, prefixes: string[]): boolean =>
133
+ prefixes.some((prefix) => s.startsWith(prefix));
134
+
135
+ export const endsWithSuffix = (s: string, suffixes: string[]): boolean => suffixes.some((suffix) => s.endsWith(suffix));
136
+
137
+ export const randomDigit = (min?: number, max?: number): number => {
138
+ min = typeof min === "number" && min >= 0 && ((max && min <= max) || typeof max !== "number") ? min : 0;
139
+ max = typeof max === "number" && max <= 9 && max >= min ? max : 9;
140
+ return Math.floor(Math.random() * (max - min + 1)) + min;
141
+ };
142
+
143
+ export const randomDigits = (n: number, min?: number, max?: number): string =>
144
+ Array.from({ length: n }, () => `${randomDigit(min, max)}`).join("");
145
+
146
+ export const isOnlyDigits = (s: string): boolean => /^\d+$/.test(s);
147
+
148
+ export const BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
149
+ export const BASE62_CHARS_WITHOUT_SIMILAR_CHARS = "23456789ABCEFGHJKLMNPRSTUVWXYZ";
150
+ export const VOWEL_CHARS = "AEOIUaeoiu";
151
+ export const DIGIT_CHARS = "0123456789";
152
+ export const VOWEL_AND_DIGIT_CHARS = VOWEL_CHARS + DIGIT_CHARS;
153
+
154
+ // "safeToken" means safe in two ways:
155
+ // 1. It's web-safe, using only characters than can appear un-encoded in URLs
156
+ // 2. It's curse-word safe: after 2 consecutive letters, the next char will be a vowel or digit
157
+ export const _randomSafeToken = (n: number, CHARS: string): string => {
158
+ let result = "";
159
+ const allowedSet = new Set(CHARS);
160
+ const vowelsAndDigits = [...VOWEL_AND_DIGIT_CHARS].filter((char) => allowedSet.has(char)).join("");
161
+
162
+ for (let i = 0; i < n; i++) {
163
+ let newChar = CHARS[Math.floor(Math.random() * CHARS.length)];
164
+
165
+ // If we have at least 2 characters and they're all alphabetic
166
+ if (
167
+ result.length >= 2 &&
168
+ isNaN(Number(result.charAt(result.length - 1))) &&
169
+ isNaN(Number(result.charAt(result.length - 2)))
170
+ ) {
171
+ // ensure next character is a vowel or a digit
172
+ newChar = vowelsAndDigits[Math.floor(Math.random() * vowelsAndDigits.length)];
173
+ }
174
+
175
+ result += newChar;
176
+ }
177
+
178
+ return result;
179
+ };
180
+ export const randomSafeToken = (n: number): string => _randomSafeToken(n, BASE62_CHARS);
181
+
182
+ export const randomSuperSafeToken = (n: number): string => _randomSafeToken(n, BASE62_CHARS_WITHOUT_SIMILAR_CHARS);
183
+
184
+ export const epochToHttpDate = (epoch?: number, clock?: ZillaClock): string =>
185
+ new Date(epoch ? epoch : clock ? clock.now() : Date.now()).toUTCString();
186
+
187
+ export const sortWords = (words: string[], dict: string[]): string[] =>
188
+ words.sort(
189
+ (a: string, b: string) =>
190
+ dict.indexOf(a) - dict.indexOf(b) || dict.length + words.indexOf(a) - (dict.length + words.indexOf(b))
191
+ );
192
+
193
+ export const trimSpaces = (str: string): string => str.trim().replace(/\s+/g, " ");
194
+
195
+ export const trimNonalphanumeric = (str: string): string => str.replace(/[^a-zA-Z0-9]/g, "");
196
+
197
+ const commonUnicodeRanges = [
198
+ [0x0000, 0x007f], // Basic Latin (ASCII)
199
+ [0x0080, 0x00ff], // Latin-1 Supplement
200
+ [0x0100, 0x017f], // Latin Extended-A
201
+ [0x0370, 0x03ff], // Greek and Coptic
202
+ [0x0400, 0x04ff], // Cyrillic
203
+ [0x0530, 0x058f], // Armenian
204
+ [0x0590, 0x05ff], // Hebrew
205
+ [0x0600, 0x06ff], // Arabic
206
+ [0x0900, 0x097f], // Devanagari
207
+ [0xac00, 0xd7af], // Hangul Syllables
208
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
209
+ ];
210
+
211
+ const createUnifiedRange = (ranges: number[][]): number[] => {
212
+ const unifiedRange = [];
213
+ for (const [start, end] of ranges) {
214
+ for (let i = start; i <= end; i++) {
215
+ unifiedRange.push(i);
216
+ }
217
+ }
218
+ return unifiedRange;
219
+ };
220
+
221
+ const unifiedUnicodeRange = createUnifiedRange(commonUnicodeRanges);
222
+
223
+ export const getRandomCodePoint = (): number => {
224
+ const randomIndex = Math.floor(Math.random() * unifiedUnicodeRange.length);
225
+ return unifiedUnicodeRange[randomIndex];
226
+ };
227
+
228
+ export const generateRandomString = (length: number): string => {
229
+ return Array.from({ length }, () => String.fromCodePoint(getRandomCodePoint())).join("");
230
+ };
231
+
232
+ export const sortObj = (o: Record<string, unknown>): Record<string, unknown> =>
233
+ Object.keys(o)
234
+ .sort()
235
+ .reduce((acc, key) => {
236
+ acc[key] =
237
+ typeof o[key] === "object" && o[key] !== null && !Array.isArray(o[key])
238
+ ? sortObj(o[key] as Record<string, unknown>)
239
+ : o[key];
240
+ return acc;
241
+ }, {} as Record<string, unknown>);
242
+
243
+ export const sortedStringify = (obj: Record<string, unknown>): string => JSON.stringify(sortObj(obj));
244
+
245
+ export const jsonHeader = () => ({ "Content-Type": "application/json" });
246
+
247
+ export const ANSI = {
248
+ RESET: "\x1b[0m",
249
+ BOLD: "\x1b[1m",
250
+ DIM: "\x1b[2m",
251
+ UNDERLINE: "\x1b[4m",
252
+ BLINK: "\x1b[5m",
253
+ INVERSE: "\x1b[7m",
254
+ HIDDEN: "\x1b[8m",
255
+
256
+ // Foreground Colors
257
+ BLACK: "\x1b[30m",
258
+ RED: "\x1b[31m",
259
+ GREEN: "\x1b[32m",
260
+ YELLOW: "\x1b[33m",
261
+ BLUE: "\x1b[34m",
262
+ MAGENTA: "\x1b[35m",
263
+ CYAN: "\x1b[36m",
264
+ WHITE: "\x1b[37m",
265
+
266
+ // Bright Foreground Colors
267
+ BRIGHT_BLACK: "\x1b[90m",
268
+ BRIGHT_RED: "\x1b[91m",
269
+ BRIGHT_GREEN: "\x1b[92m",
270
+ BRIGHT_YELLOW: "\x1b[93m",
271
+ BRIGHT_BLUE: "\x1b[94m",
272
+ BRIGHT_MAGENTA: "\x1b[95m",
273
+ BRIGHT_CYAN: "\x1b[96m",
274
+ BRIGHT_WHITE: "\x1b[97m",
275
+ };
276
+
277
+ export const CSS = {
278
+ RESET: "color: inherit;",
279
+ BOLD: "font-weight: bold;",
280
+ DIM: "opacity: 0.7;",
281
+ UNDERLINE: "text-decoration: underline;",
282
+ // for BLINK to work, add the CSS below to some global CSS file that the browser will load:
283
+ // @keyframes blink { 50% { opacity: 0; } }
284
+ // If the CSS isn't defined, BLINK text will still appear, it just won't blink
285
+ BLINK: "animation: blink 1s step-start infinite;",
286
+ INVERSE: "filter: invert(100%);",
287
+ HIDDEN: "visibility: hidden;",
288
+
289
+ // Foreground Colors
290
+ BLACK: "color: black;",
291
+ RED: "color: red;",
292
+ GREEN: "color: green;",
293
+ YELLOW: "color: goldenrod;",
294
+ BLUE: "color: blue;",
295
+ MAGENTA: "color: purple;",
296
+ CYAN: "color: cyan;",
297
+ WHITE: "color: white;",
298
+
299
+ // Bright Foreground Colors
300
+ BRIGHT_BLACK: "color: gray;",
301
+ BRIGHT_RED: "color: lightcoral;",
302
+ BRIGHT_GREEN: "color: lightgreen;",
303
+ BRIGHT_YELLOW: "color: yellow;",
304
+ BRIGHT_BLUE: "color: lightskyblue;",
305
+ BRIGHT_MAGENTA: "color: violet;",
306
+ BRIGHT_CYAN: "color: lightcyan;",
307
+ BRIGHT_WHITE: "color: white;",
308
+ };
309
+
310
+ export const REGEX_ARRAY_OF_STRINGS = /^\[\s*(?:"[^"]*"\s*(?:,\s*"[^"]*"\s*)*)?]$/;
311
+ export const REGEX_ARRAY_OF_BOOLEANS = /^\[\s*(?:true|false)(\s*,\s*(?:true|false))*\s*]$/;
312
+ export const REGEX_ARRAY_OF_INTEGERS = /^\[\s*-?\d+(\s*,\s*-?\d+)*\s*]$/;
313
+ export const REGEX_ARRAY_OF_FLOATS = /^\[\s*-?\d+(\.\d+)?([eE][-+]?\d+)?(\s*,\s*-?\d+(\.\d+)?([eE][-+]?\d+)?)*\s*]$/;
314
+
315
+ export const safeStringify = (obj: unknown, logger?: GenericLogger): string => {
316
+ const seen: WeakSet<object> = new WeakSet();
317
+ return JSON.stringify(obj, (_key, val) => {
318
+ if (typeof val === "object" && val !== null) {
319
+ if (seen.has(val)) {
320
+ if (logger && logger.isDebugEnabled()) {
321
+ logger.debug(
322
+ `safeStringify: detected circular reference in obj: ${obj}${
323
+ obj?.constructor?.name ? ` [constructor=${obj.constructor.name}]` : ""
324
+ }`
325
+ );
326
+ }
327
+ return "[Circular]";
328
+ }
329
+ seen.add(val);
330
+ }
331
+ return val;
332
+ });
333
+ };
334
+
335
+ export const splitter = new GraphemeSplitter();
336
+
337
+ export const countVisibleChars = (data: Uint8Array | string): number =>
338
+ splitter.countGraphemes(typeof data === "string" ? data : new TextDecoder("utf-8").decode(data));
package/src/subst.ts ADDED
@@ -0,0 +1,41 @@
1
+ export type StringXformFunction = (args?: string[]) => string;
2
+
3
+ export type StringXformContext = Record<string, string | StringXformFunction | undefined | null>;
4
+
5
+ export const ERR_UNKNOWN_SUBST_CONTEXT_VAR = "Unknown context variable";
6
+
7
+ export const substContext = (obj: unknown, context: StringXformContext, opts?: { strict: boolean }): unknown => {
8
+ const transformString = (str: string): string => {
9
+ return str.replace(/{{(.*?)}}/g, (_, key) => {
10
+ const parts: string[] = key
11
+ .trim()
12
+ .split(/\s+/)
13
+ .filter((s: string) => s.length > 0)
14
+ .map((s: string) => s.trim());
15
+ const k = parts[0];
16
+ const args = parts.length > 1 ? parts.slice(1) : [];
17
+
18
+ const subst = context[k];
19
+
20
+ if (typeof subst === "string") return subst;
21
+ if (typeof subst === "function") {
22
+ return subst(args);
23
+ }
24
+ if (!opts || opts?.strict !== false) throw new Error(`${ERR_UNKNOWN_SUBST_CONTEXT_VAR} var=${k}`);
25
+ return `??${k}`;
26
+ });
27
+ };
28
+
29
+ const transform = (item: unknown): unknown => {
30
+ if (typeof item === "string") {
31
+ return transformString(item);
32
+ } else if (Array.isArray(item)) {
33
+ return item.map(transform);
34
+ } else if (item && typeof item === "object") {
35
+ return Object.fromEntries(Object.entries(item).map(([k, v]) => [k, transform(v)]));
36
+ }
37
+ return item;
38
+ };
39
+
40
+ return transform(obj);
41
+ };
package/src/time.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { uuidv4 } from "./string.js";
2
+ import { GenericLogger } from "./logger.js";
3
+
4
+ export type ZillaNowFunc = () => number;
5
+
6
+ const DEFAULT_NOW_FUNC: ZillaNowFunc = () => Date.now();
7
+
8
+ export type ZillaClock = {
9
+ now: ZillaNowFunc;
10
+ };
11
+
12
+ export const DEFAULT_CLOCK: ZillaClock = {
13
+ now: DEFAULT_NOW_FUNC,
14
+ };
15
+
16
+ export type ZillaClockSource = () => ZillaClock;
17
+
18
+ export const DEFAULT_CLOCK_SOURCE: ZillaClockSource = () => DEFAULT_CLOCK;
19
+
20
+ export class MockClock {
21
+ private time: number;
22
+ private start: number;
23
+ private logger?: GenericLogger;
24
+ private name: string;
25
+ constructor(startTime?: number, logger?: GenericLogger, name?: string) {
26
+ this.start = Date.now();
27
+ this.time = typeof startTime === "number" && startTime > 0 ? startTime : Date.now();
28
+ this.logger = logger;
29
+ this.name = name ? name : `MockClock-${uuidv4()}`;
30
+ }
31
+ now(): number {
32
+ if (this.logger && this.logger.isTraceEnabled()) {
33
+ this.logger.trace(`MockClock ID=${this.name} RETURNING now=${this.time}`);
34
+ }
35
+ return this.time + (Date.now() - this.start);
36
+ }
37
+ setTime(time: number) {
38
+ if (this.logger && this.logger.isTraceEnabled()) {
39
+ this.logger.trace(`MockClock ID=${this.name} SET-TIME now=${this.time} setting-to=${time}`);
40
+ }
41
+ this.time = time;
42
+ if (this.logger && this.logger.isTraceEnabled()) {
43
+ this.logger.trace(`MockClock ID=${this.name} SET-TIME now=${this.time}`);
44
+ }
45
+ }
46
+ advance(timeDelta: number): void {
47
+ if (this.logger && this.logger.isTraceEnabled()) {
48
+ this.logger.trace(`MockClock ID=${this.name} ADVANCING now=${this.time} timeDelta=${timeDelta}`);
49
+ }
50
+ this.time += timeDelta;
51
+ if (this.logger && this.logger.isTraceEnabled()) {
52
+ this.logger.trace(`MockClock ID=${this.name} ADVANCED now=${this.time}`);
53
+ }
54
+ }
55
+ }
56
+ export const mockClock = (startTime: number = 0): ZillaClock => new MockClock(startTime);
57
+
58
+ // adapted from https://stackoverflow.com/a/39914235
59
+ export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
60
+
61
+ export const DEFAULT_NAP_CHECK = 1000;
62
+
63
+ const _nap =
64
+ (resolve: (v?: unknown) => void, clock: ZillaClock, alarm: NapAlarm, ms: number, check: number) => async () => {
65
+ try {
66
+ alarm.wake = false;
67
+ const start = clock.now();
68
+ while (!alarm.wake && clock.now() - start < ms) {
69
+ await sleep(check);
70
+ }
71
+ } finally {
72
+ resolve();
73
+ }
74
+ };
75
+
76
+ export type NapAlarm = {
77
+ wake?: boolean;
78
+ };
79
+
80
+ export const nap = (clock: ZillaClock, alarm: NapAlarm, ms: number, check?: number) =>
81
+ new Promise((r) => _nap(r, clock, alarm, ms, check || DEFAULT_NAP_CHECK)());
82
+
83
+ export const parseSimpleTime = (t: string): number => {
84
+ const match: RegExpExecArray | null = /^(\d+(\.\d+)?)([smhd])?$/.exec(t);
85
+ if (!match) throw new Error(`Invalid time format: ${t}`);
86
+ const value: number = Number(match[1]);
87
+ const unit: string | undefined = match[3];
88
+ switch (unit) {
89
+ case "s":
90
+ return value * 1000;
91
+ case "m":
92
+ return value * 60 * 1000;
93
+ case "h":
94
+ return value * 3600 * 1000;
95
+ case "d":
96
+ return value * 24 * 3600 * 1000;
97
+ default:
98
+ return value;
99
+ }
100
+ };
101
+
102
+ export const delay = async (duration?: number | string) => {
103
+ if (!duration) return;
104
+ await sleep(typeof duration === "number" ? duration : parseSimpleTime(duration));
105
+ };
package/src/url.ts ADDED
@@ -0,0 +1,92 @@
1
+ export type CanonicalizedUrl = {
2
+ original: string;
3
+ normalized: string;
4
+ redirectChain: string[];
5
+ landing: string;
6
+ canonical: string;
7
+ };
8
+
9
+ const tracking: Set<string> = new Set([
10
+ "ref",
11
+ "ref_src",
12
+ "gclsrc",
13
+ "fbp",
14
+ "fbc",
15
+ "irclickid",
16
+ "hsCtaTracking",
17
+ "pi_opt_in",
18
+ "visitor_id",
19
+ "mc_cid",
20
+ "mc_eid",
21
+ ]);
22
+
23
+ const trackingRegexes: RegExp[] = [
24
+ /^[a-z_]+clid$/,
25
+ /^[a-z_]+clkid$/,
26
+ /^[a-z_]+click_?id$/,
27
+ /^utm_[a-z_]+$/,
28
+ /^__utm(?:a|b|c|z|v)$/,
29
+ /^_hs(?:enc|mi)$/,
30
+ /^mkt_[a-z_]+$/,
31
+ /^pk_[a-z]+$/,
32
+ ];
33
+
34
+ export const normalizeUrl = (input: string): string => {
35
+ try {
36
+ return new URL(input).toString();
37
+ } catch {
38
+ return input;
39
+ }
40
+ };
41
+
42
+ export const canonicalizeUrl = async (url: string): Promise<CanonicalizedUrl> => {
43
+ const original: string = url.trim();
44
+
45
+ const u: URL = new URL(original);
46
+ u.protocol = u.protocol.toLowerCase();
47
+ u.hostname = u.hostname.toLowerCase();
48
+ if ((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443")) u.port = "";
49
+ u.pathname = u.pathname.replace(/\/{2,}/g, "/").replace(/%7E/gi, "~");
50
+
51
+ const kept: [string, string][] = Array.from(u.searchParams.entries()).filter(
52
+ ([k]) => !tracking.has(k.toLowerCase()) && !trackingRegexes.find((r) => r.test(k.toLowerCase()))
53
+ );
54
+ kept.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
55
+ u.search = kept.length ? `?${new URLSearchParams(kept).toString()}` : "";
56
+
57
+ const normalized: string = u.toString();
58
+
59
+ const redirectChain: string[] = [];
60
+ const maxRedirects: number = 5;
61
+ let landing: string = normalized;
62
+ try {
63
+ let cur: string = normalized;
64
+ for (let n: number = 0; n < maxRedirects; n += 1) {
65
+ const r: Response = await fetch(cur, { method: "HEAD", redirect: "manual" });
66
+ const loc: string | null = r.status >= 300 && r.status < 400 ? r.headers.get("location") : null;
67
+ if (!loc) break;
68
+ cur = new URL(loc, cur).toString();
69
+ redirectChain.push(cur);
70
+ }
71
+ if (redirectChain.length) landing = redirectChain[redirectChain.length - 1];
72
+ } catch {
73
+ landing = normalized;
74
+ }
75
+
76
+ let canonical: string = landing;
77
+ try {
78
+ const res: Response = await fetch(landing, { method: "GET", redirect: "follow" });
79
+ if (res.headers.get("content-type")?.includes("text/html")) {
80
+ const html: string = await res.text();
81
+ const found: string | undefined =
82
+ html.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/i)?.[1] ??
83
+ html.match(/<meta[^>]*property=["']og:url["'][^>]*content=["']([^"']+)["']/i)?.[1] ??
84
+ html.match(/<meta[^>]*property=["']twitter:url["'][^>]*content=["']([^"']+)["']/i)?.[1];
85
+ if (found) canonical = new URL(found, landing).toString();
86
+ }
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+
91
+ return { original, normalized, redirectChain, landing, canonical };
92
+ };