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.
- package/LICENSE.txt +202 -0
- package/lib/esm/array.d.ts +30 -0
- package/lib/esm/array.js +111 -0
- package/lib/esm/array.js.map +1 -0
- package/lib/esm/base64.d.ts +2 -0
- package/lib/esm/base64.js +51 -0
- package/lib/esm/base64.js.map +1 -0
- package/lib/esm/crypto.d.ts +16 -0
- package/lib/esm/crypto.js +182 -0
- package/lib/esm/crypto.js.map +1 -0
- package/lib/esm/date.d.ts +2 -0
- package/lib/esm/date.js +47 -0
- package/lib/esm/date.js.map +1 -0
- package/lib/esm/deep.d.ts +24 -0
- package/lib/esm/deep.js +318 -0
- package/lib/esm/deep.js.map +1 -0
- package/lib/esm/error.d.ts +1 -0
- package/lib/esm/error.js +9 -0
- package/lib/esm/error.js.map +1 -0
- package/lib/esm/hash.d.ts +4 -0
- package/lib/esm/hash.js +18 -0
- package/lib/esm/hash.js.map +1 -0
- package/lib/esm/index.d.ts +17 -0
- package/lib/esm/index.js +18 -0
- package/lib/esm/index.js.map +1 -0
- package/lib/esm/logger.d.ts +18 -0
- package/lib/esm/logger.js +18 -0
- package/lib/esm/logger.js.map +1 -0
- package/lib/esm/lru.d.ts +25 -0
- package/lib/esm/lru.js +137 -0
- package/lib/esm/lru.js.map +1 -0
- package/lib/esm/msg.d.ts +29 -0
- package/lib/esm/msg.js +7 -0
- package/lib/esm/msg.js.map +1 -0
- package/lib/esm/object.d.ts +8 -0
- package/lib/esm/object.js +31 -0
- package/lib/esm/object.js.map +1 -0
- package/lib/esm/package.d.ts +2 -0
- package/lib/esm/package.js +38 -0
- package/lib/esm/package.js.map +1 -0
- package/lib/esm/regex.d.ts +1 -0
- package/lib/esm/regex.js +22 -0
- package/lib/esm/regex.js.map +1 -0
- package/lib/esm/retry.d.ts +13 -0
- package/lib/esm/retry.js +57 -0
- package/lib/esm/retry.js.map +1 -0
- package/lib/esm/sha256.d.ts +50 -0
- package/lib/esm/sha256.js +181 -0
- package/lib/esm/sha256.js.map +1 -0
- package/lib/esm/string.d.ts +106 -0
- package/lib/esm/string.js +257 -0
- package/lib/esm/string.js.map +1 -0
- package/lib/esm/subst.d.ts +6 -0
- package/lib/esm/subst.js +37 -0
- package/lib/esm/subst.js.map +1 -0
- package/lib/esm/time.d.ts +27 -0
- package/lib/esm/time.js +84 -0
- package/lib/esm/time.js.map +1 -0
- package/lib/esm/url.d.ts +9 -0
- package/lib/esm/url.js +80 -0
- package/lib/esm/url.js.map +1 -0
- package/package.json +72 -0
- package/src/array.ts +134 -0
- package/src/base64.ts +53 -0
- package/src/crypto.ts +281 -0
- package/src/date.ts +48 -0
- package/src/deep.ts +348 -0
- package/src/error.ts +7 -0
- package/src/hash.ts +20 -0
- package/src/index.ts +17 -0
- package/src/logger.ts +35 -0
- package/src/lru.ts +187 -0
- package/src/msg.ts +33 -0
- package/src/object.ts +43 -0
- package/src/package.ts +39 -0
- package/src/retry.ts +71 -0
- package/src/string.ts +338 -0
- package/src/subst.ts +41 -0
- package/src/time.ts +105 -0
- 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
|
+
};
|