@linxiraos/pi-utils 1.0.0
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/CHANGELOG.md +449 -0
- package/README.md +36 -0
- package/dist/types/abortable.d.ts +32 -0
- package/dist/types/acp/connection.d.ts +118 -0
- package/dist/types/acp/protocol.d.ts +526 -0
- package/dist/types/acp/schema.d.ts +41 -0
- package/dist/types/acp/stream.d.ts +8 -0
- package/dist/types/acp/transport.d.ts +81 -0
- package/dist/types/acp.d.ts +6 -0
- package/dist/types/async.d.ts +20 -0
- package/dist/types/binary.d.ts +17 -0
- package/dist/types/browsers.d.ts +68 -0
- package/dist/types/chalk.d.ts +125 -0
- package/dist/types/cli.d.ts +134 -0
- package/dist/types/color.d.ts +102 -0
- package/dist/types/dates.d.ts +7 -0
- package/dist/types/dirs.d.ts +282 -0
- package/dist/types/docx/converter.d.ts +46 -0
- package/dist/types/docx/xml.d.ts +26 -0
- package/dist/types/docx/zip.d.ts +6 -0
- package/dist/types/docx.d.ts +11 -0
- package/dist/types/dom/core.d.ts +431 -0
- package/dist/types/dom/parser.d.ts +7 -0
- package/dist/types/dom/selector.d.ts +5 -0
- package/dist/types/dom.d.ts +5 -0
- package/dist/types/env.d.ts +124 -0
- package/dist/types/fetch-retry.d.ts +96 -0
- package/dist/types/file-lock.d.ts +23 -0
- package/dist/types/format.d.ts +37 -0
- package/dist/types/frontmatter.d.ts +46 -0
- package/dist/types/fs-error.d.ts +31 -0
- package/dist/types/glob.d.ts +28 -0
- package/dist/types/headers.d.ts +34 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/json-parse.d.ts +74 -0
- package/dist/types/json.d.ts +14 -0
- package/dist/types/logger/rotating-file.d.ts +18 -0
- package/dist/types/logger.d.ts +96 -0
- package/dist/types/loop-phase.d.ts +10 -0
- package/dist/types/lru.d.ts +46 -0
- package/dist/types/marked/core.d.ts +445 -0
- package/dist/types/marked.d.ts +2 -0
- package/dist/types/mermaid-ascii.d.ts +11 -0
- package/dist/types/mime.d.ts +29 -0
- package/dist/types/module-timer.d.ts +1 -0
- package/dist/types/path-tree.d.ts +76 -0
- package/dist/types/path.d.ts +2 -0
- package/dist/types/peek-file.d.ts +29 -0
- package/dist/types/postmortem.d.ts +104 -0
- package/dist/types/process-name.d.ts +7 -0
- package/dist/types/procmgr.d.ts +74 -0
- package/dist/types/prompt.d.ts +18 -0
- package/dist/types/ptree.d.ts +110 -0
- package/dist/types/readability/readability.d.ts +9 -0
- package/dist/types/readability/readerable.d.ts +10 -0
- package/dist/types/readability/types.d.ts +70 -0
- package/dist/types/readability.d.ts +4 -0
- package/dist/types/ring.d.ts +93 -0
- package/dist/types/runtime-install.d.ts +78 -0
- package/dist/types/sanitize-text.d.ts +29 -0
- package/dist/types/snowflake.d.ts +25 -0
- package/dist/types/stderr-guard.d.ts +22 -0
- package/dist/types/stream.d.ts +67 -0
- package/dist/types/tab-spacing.d.ts +24 -0
- package/dist/types/temp.d.ts +17 -0
- package/dist/types/template.d.ts +62 -0
- package/dist/types/timing-buffer.d.ts +22 -0
- package/dist/types/tls-fetch.d.ts +37 -0
- package/dist/types/turndown/gfm.d.ts +11 -0
- package/dist/types/turndown/html.d.ts +5 -0
- package/dist/types/turndown/service.d.ts +21 -0
- package/dist/types/turndown/types.d.ts +70 -0
- package/dist/types/turndown.d.ts +4 -0
- package/dist/types/type-guards.d.ts +5 -0
- package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +41 -0
- package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +89 -0
- package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +12 -0
- package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +66 -0
- package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +48 -0
- package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +43 -0
- package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +56 -0
- package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +65 -0
- package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +27 -0
- package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +17 -0
- package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +34 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +26 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +31 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +59 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +17 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +30 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +55 -0
- package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +206 -0
- package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +51 -0
- package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +2 -0
- package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/class/types.d.ts +102 -0
- package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/er/types.d.ts +76 -0
- package/dist/types/vendor/mermaid-ascii/index.d.ts +1 -0
- package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +9 -0
- package/dist/types/vendor/mermaid-ascii/parser.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +130 -0
- package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +21 -0
- package/dist/types/vendor/mermaid-ascii/types.d.ts +114 -0
- package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +25 -0
- package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +145 -0
- package/dist/types/version.d.ts +18 -0
- package/dist/types/vterm/buffer.d.ts +99 -0
- package/dist/types/vterm/terminal.d.ts +44 -0
- package/dist/types/vterm.d.ts +8 -0
- package/dist/types/which.d.ts +37 -0
- package/dist/types/worker-host.d.ts +51 -0
- package/dist/types/xml.d.ts +31 -0
- package/package.json +59 -0
- package/src/abortable.ts +109 -0
- package/src/acp/connection.ts +344 -0
- package/src/acp/protocol.ts +466 -0
- package/src/acp/schema.ts +160 -0
- package/src/acp/stream.ts +82 -0
- package/src/acp/transport.ts +213 -0
- package/src/acp.ts +6 -0
- package/src/async.ts +91 -0
- package/src/binary.ts +50 -0
- package/src/browsers.ts +501 -0
- package/src/chalk.ts +312 -0
- package/src/cli.ts +529 -0
- package/src/color.ts +302 -0
- package/src/dates.ts +194 -0
- package/src/dirs.ts +1026 -0
- package/src/docx/converter.ts +681 -0
- package/src/docx/xml.ts +166 -0
- package/src/docx/zip.ts +87 -0
- package/src/docx.ts +20 -0
- package/src/dom/core.ts +1254 -0
- package/src/dom/parser.ts +370 -0
- package/src/dom/selector.ts +290 -0
- package/src/dom.ts +33 -0
- package/src/env.ts +400 -0
- package/src/fetch-retry.ts +379 -0
- package/src/file-lock.ts +69 -0
- package/src/format.ts +113 -0
- package/src/frontmatter.ts +205 -0
- package/src/fs-error.ts +56 -0
- package/src/glob.ts +189 -0
- package/src/headers.ts +167 -0
- package/src/index.ts +59 -0
- package/src/json-parse.ts +821 -0
- package/src/json.ts +23 -0
- package/src/logger/rotating-file.ts +149 -0
- package/src/logger.ts +798 -0
- package/src/loop-phase.ts +49 -0
- package/src/lru.ts +185 -0
- package/src/marked/core.ts +1576 -0
- package/src/marked.ts +2 -0
- package/src/mermaid-ascii.ts +31 -0
- package/src/mime.ts +159 -0
- package/src/module-timer.ts +148 -0
- package/src/path-tree.ts +147 -0
- package/src/path.ts +28 -0
- package/src/peek-file.ts +188 -0
- package/src/postmortem.ts +433 -0
- package/src/process-name.ts +57 -0
- package/src/procmgr.ts +247 -0
- package/src/prompt.ts +545 -0
- package/src/ptree.ts +412 -0
- package/src/readability/readability.ts +533 -0
- package/src/readability/readerable.ts +51 -0
- package/src/readability/types.ts +72 -0
- package/src/readability.ts +11 -0
- package/src/ring.ts +169 -0
- package/src/runtime-install.ts +393 -0
- package/src/sanitize-text.ts +96 -0
- package/src/snowflake.ts +121 -0
- package/src/stderr-guard.ts +155 -0
- package/src/stream.ts +454 -0
- package/src/tab-spacing.ts +336 -0
- package/src/temp.ts +134 -0
- package/src/template.ts +586 -0
- package/src/timing-buffer.ts +47 -0
- package/src/tls-fetch.ts +178 -0
- package/src/turndown/gfm.ts +106 -0
- package/src/turndown/html.ts +257 -0
- package/src/turndown/service.ts +334 -0
- package/src/turndown/types.ts +81 -0
- package/src/turndown.ts +5 -0
- package/src/type-guards.ts +17 -0
- package/src/vendor/mermaid-ascii/NOTICE +33 -0
- package/src/vendor/mermaid-ascii/ascii/ansi.ts +409 -0
- package/src/vendor/mermaid-ascii/ascii/canvas.ts +476 -0
- package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +699 -0
- package/src/vendor/mermaid-ascii/ascii/converter.ts +271 -0
- package/src/vendor/mermaid-ascii/ascii/draw.ts +1382 -0
- package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +328 -0
- package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +297 -0
- package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +441 -0
- package/src/vendor/mermaid-ascii/ascii/grid.ts +578 -0
- package/src/vendor/mermaid-ascii/ascii/index.ts +187 -0
- package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +78 -0
- package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +277 -0
- package/src/vendor/mermaid-ascii/ascii/sequence.ts +460 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +127 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +101 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +175 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +296 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +114 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +192 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +73 -0
- package/src/vendor/mermaid-ascii/ascii/types.ts +273 -0
- package/src/vendor/mermaid-ascii/ascii/validate.ts +120 -0
- package/src/vendor/mermaid-ascii/ascii/xychart.ts +875 -0
- package/src/vendor/mermaid-ascii/class/parser.ts +290 -0
- package/src/vendor/mermaid-ascii/class/types.ts +121 -0
- package/src/vendor/mermaid-ascii/er/parser.ts +181 -0
- package/src/vendor/mermaid-ascii/er/types.ts +91 -0
- package/src/vendor/mermaid-ascii/index.ts +14 -0
- package/src/vendor/mermaid-ascii/multiline-utils.ts +30 -0
- package/src/vendor/mermaid-ascii/parser.ts +645 -0
- package/src/vendor/mermaid-ascii/sequence/parser.ts +207 -0
- package/src/vendor/mermaid-ascii/sequence/types.ts +146 -0
- package/src/vendor/mermaid-ascii/text-metrics.ts +71 -0
- package/src/vendor/mermaid-ascii/types.ts +164 -0
- package/src/vendor/mermaid-ascii/xychart/colors.ts +140 -0
- package/src/vendor/mermaid-ascii/xychart/parser.ts +115 -0
- package/src/vendor/mermaid-ascii/xychart/types.ts +150 -0
- package/src/version.ts +99 -0
- package/src/vterm/buffer.ts +218 -0
- package/src/vterm/terminal.ts +773 -0
- package/src/vterm.ts +8 -0
- package/src/which.ts +234 -0
- package/src/worker-host.ts +100 -0
- package/src/xml.ts +313 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { scheduler } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
// "reset after 1h2m3s" / "10m15s" / "39s"
|
|
4
|
+
const QUOTA_RESET_PATTERN = /reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i;
|
|
5
|
+
// "Please retry in 250ms" / "Please retry in 12s"
|
|
6
|
+
const PLEASE_RETRY_PATTERN = /Please retry in ([0-9.]+)(ms|s)/i;
|
|
7
|
+
// JSON field: "retryDelay": "34.074824224s"
|
|
8
|
+
const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i;
|
|
9
|
+
// "try again in 250ms" / "try again in 12s" / "try again in 12sec" /
|
|
10
|
+
// "try again in 5 min" / "try again in ~158 min." / "try again in 2h" /
|
|
11
|
+
// "try again in 90 minutes" / "try again in 1 hour"
|
|
12
|
+
const TRY_AGAIN_PATTERN = /try again in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
|
|
13
|
+
// "Your limit will reset in 13 minutes" / "reset in 13 minutes" / "will reset in 2h"
|
|
14
|
+
const WILL_RESET_IN_PATTERN = /(?:will\s+)?reset in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Server-suggested retry delay extraction. Merges the patterns historically used
|
|
18
|
+
* by the OpenAI Codex and Google Gemini retry helpers.
|
|
19
|
+
*
|
|
20
|
+
* Header sources (checked in order):
|
|
21
|
+
* - `retry-after-ms` (milliseconds)
|
|
22
|
+
* - `Retry-After` (numeric seconds, or HTTP date)
|
|
23
|
+
* - `x-ratelimit-reset-ms` (delta ms, or Unix epoch ms/s for large values)
|
|
24
|
+
* - `x-ratelimit-reset` (Unix epoch seconds)
|
|
25
|
+
* - `x-ratelimit-reset-after` (seconds)
|
|
26
|
+
*
|
|
27
|
+
* Body patterns:
|
|
28
|
+
* - `Your quota will reset after 18h31m10s` / `10m15s` / `39s`
|
|
29
|
+
* - `Please retry in 250ms` / `Please retry in 12s`
|
|
30
|
+
* - `"retryDelay": "34.074824224s"` (JSON error detail field)
|
|
31
|
+
* - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
|
|
32
|
+
*
|
|
33
|
+
* Returns `undefined` if no signal is found.
|
|
34
|
+
*/
|
|
35
|
+
export function extractRetryHint(source: Response | Headers | null | undefined, body?: string): number | undefined {
|
|
36
|
+
const headers = source instanceof Headers ? source : (source?.headers ?? undefined);
|
|
37
|
+
if (headers) {
|
|
38
|
+
const retryAfterMs = headers.get("retry-after-ms");
|
|
39
|
+
if (retryAfterMs) {
|
|
40
|
+
const ms = Number(retryAfterMs);
|
|
41
|
+
if (Number.isFinite(ms) && ms >= 0) return ms;
|
|
42
|
+
}
|
|
43
|
+
const retryAfter = headers.get("retry-after");
|
|
44
|
+
if (retryAfter) {
|
|
45
|
+
const seconds = Number(retryAfter);
|
|
46
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
47
|
+
const parsedDate = Date.parse(retryAfter);
|
|
48
|
+
if (!Number.isNaN(parsedDate)) return Math.max(0, parsedDate - Date.now());
|
|
49
|
+
}
|
|
50
|
+
const rateLimitResetMs = headers.get("x-ratelimit-reset-ms");
|
|
51
|
+
if (rateLimitResetMs) {
|
|
52
|
+
const value = Number(rateLimitResetMs);
|
|
53
|
+
if (Number.isFinite(value) && value > 0) {
|
|
54
|
+
// > 1e12 → epoch ms; > 1e9 → epoch s; otherwise a delta in ms.
|
|
55
|
+
const targetMs = value > 1e12 ? value : value > 1e9 ? value * 1000 : undefined;
|
|
56
|
+
if (targetMs === undefined) return value;
|
|
57
|
+
const delta = targetMs - Date.now();
|
|
58
|
+
if (delta > 0) return delta;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const rateLimitReset = headers.get("x-ratelimit-reset");
|
|
62
|
+
if (rateLimitReset) {
|
|
63
|
+
const resetSeconds = Number.parseInt(rateLimitReset, 10);
|
|
64
|
+
if (!Number.isNaN(resetSeconds)) {
|
|
65
|
+
const delta = resetSeconds * 1000 - Date.now();
|
|
66
|
+
if (delta > 0) return delta;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const rateLimitResetAfter = headers.get("x-ratelimit-reset-after");
|
|
70
|
+
if (rateLimitResetAfter) {
|
|
71
|
+
const seconds = Number(rateLimitResetAfter);
|
|
72
|
+
if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!body) return undefined;
|
|
77
|
+
|
|
78
|
+
const quotaMatch = QUOTA_RESET_PATTERN.exec(body);
|
|
79
|
+
if (quotaMatch) {
|
|
80
|
+
const hours = quotaMatch[1] ? Number.parseInt(quotaMatch[1], 10) : 0;
|
|
81
|
+
const minutes = quotaMatch[2] ? Number.parseInt(quotaMatch[2], 10) : 0;
|
|
82
|
+
const seconds = Number.parseFloat(quotaMatch[3]!);
|
|
83
|
+
if (!Number.isNaN(seconds)) {
|
|
84
|
+
const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000;
|
|
85
|
+
if (totalMs > 0) return totalMs;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Account-reset hints ("will reset in …") take precedence over short
|
|
89
|
+
// retry hints ("please retry in 5s"): a body carrying both must honour the
|
|
90
|
+
// longer account window, not the shorter generic one. QUOTA_RESET_PATTERN
|
|
91
|
+
// ("reset after …") above already runs first and stays first.
|
|
92
|
+
for (const pattern of [WILL_RESET_IN_PATTERN, PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) {
|
|
93
|
+
const match = pattern.exec(body);
|
|
94
|
+
if (match?.[1]) {
|
|
95
|
+
const value = Number.parseFloat(match[1]);
|
|
96
|
+
if (Number.isFinite(value) && value > 0) {
|
|
97
|
+
const unitMs = unitToMs(match[2]!);
|
|
98
|
+
if (unitMs !== undefined) return value * unitMs;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function unitToMs(unit: string): number | undefined {
|
|
106
|
+
switch (unit.toLowerCase()) {
|
|
107
|
+
case "ms":
|
|
108
|
+
return 1;
|
|
109
|
+
case "s":
|
|
110
|
+
case "sec":
|
|
111
|
+
return 1000;
|
|
112
|
+
case "m":
|
|
113
|
+
case "min":
|
|
114
|
+
case "mins":
|
|
115
|
+
case "minute":
|
|
116
|
+
case "minutes":
|
|
117
|
+
return 60_000;
|
|
118
|
+
case "h":
|
|
119
|
+
case "hr":
|
|
120
|
+
case "hrs":
|
|
121
|
+
case "hour":
|
|
122
|
+
case "hours":
|
|
123
|
+
return 60 * 60_000;
|
|
124
|
+
default:
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface FetchWithRetryOptions extends RequestInit {
|
|
130
|
+
/** Total fetch attempts (initial + retries). Default `5`. */
|
|
131
|
+
maxAttempts?: number;
|
|
132
|
+
/**
|
|
133
|
+
* Per-delay cap. Server-provided `Retry-After` hints exceeding this return
|
|
134
|
+
* the current response immediately — caller deals with the `!response.ok`.
|
|
135
|
+
* Default `60_000`.
|
|
136
|
+
*/
|
|
137
|
+
maxDelayMs?: number;
|
|
138
|
+
/**
|
|
139
|
+
* Fallback delay schedule when no server hint is present. Number, array
|
|
140
|
+
* (indexed by attempt, clamped to last), or function. Default exponential
|
|
141
|
+
* `500ms * 2 ** attempt` capped at `maxDelayMs`.
|
|
142
|
+
*/
|
|
143
|
+
defaultDelayMs?: number | readonly number[] | ((attempt: number) => number);
|
|
144
|
+
/**
|
|
145
|
+
* Optional per-attempt overlay merged into the base `RequestInit` each try.
|
|
146
|
+
* Headers from the overlay shallow-merge over the base. Useful for auth
|
|
147
|
+
* token refresh or user-agent rotation.
|
|
148
|
+
*/
|
|
149
|
+
prepareInit?: (attempt: number) => RequestInit | Promise<RequestInit>;
|
|
150
|
+
/**
|
|
151
|
+
* Optional `fetch` implementation override. Defaults to `globalThis.fetch`.
|
|
152
|
+
* Useful for routing requests through a proxy, instrumented transport, or
|
|
153
|
+
* mock during tests.
|
|
154
|
+
*/
|
|
155
|
+
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
156
|
+
/**
|
|
157
|
+
* Optional retry gate for HTTP responses whose status is retryable. Receives a
|
|
158
|
+
* cloned body string so callers can fail fast on deterministic provider
|
|
159
|
+
* failures that happen to use a 5xx status.
|
|
160
|
+
*/
|
|
161
|
+
shouldRetryResponse?: (response: Response, bodyText: string, attempt: number) => boolean | Promise<boolean>;
|
|
162
|
+
/**
|
|
163
|
+
* Bun extension forwarded verbatim to the underlying `fetch` call. `false`
|
|
164
|
+
* disables Bun's native ~300s pre-response timeout (callers that own a
|
|
165
|
+
* configurable first-event/idle watchdog or an external `AbortSignal`
|
|
166
|
+
* supply this so the runtime ceiling cannot pre-empt them); a positive
|
|
167
|
+
* number sets a custom ceiling in ms. Bare browser/Node fetch ignores it.
|
|
168
|
+
*/
|
|
169
|
+
timeout?: number | false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const DEFAULT_MAX_DELAY_MS = 60_000;
|
|
173
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Fetch with bounded retries and sensible defaults. Retries on any
|
|
177
|
+
* `isRetryableStatus` (5xx, 408, 429) and on transient network errors. Server
|
|
178
|
+
* `Retry-After`/quota hints are honoured up to `maxDelayMs`; a hint that exceeds
|
|
179
|
+
* the cap returns the current response so the caller can fail fast. Aborts on
|
|
180
|
+
* `init.signal` propagate as `"Request was aborted"`.
|
|
181
|
+
*
|
|
182
|
+
* The caller is responsible for inspecting `!response.ok` once the call returns.
|
|
183
|
+
*/
|
|
184
|
+
export async function fetchWithRetry(
|
|
185
|
+
url: string | URL | ((attempt: number) => string | URL),
|
|
186
|
+
options: FetchWithRetryOptions = {},
|
|
187
|
+
): Promise<Response> {
|
|
188
|
+
const {
|
|
189
|
+
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
190
|
+
maxDelayMs = DEFAULT_MAX_DELAY_MS,
|
|
191
|
+
defaultDelayMs,
|
|
192
|
+
prepareInit,
|
|
193
|
+
shouldRetryResponse,
|
|
194
|
+
fetch: fetchImpl = fetch,
|
|
195
|
+
timeout = false,
|
|
196
|
+
...baseInit
|
|
197
|
+
} = options;
|
|
198
|
+
const signal = baseInit.signal as AbortSignal | undefined;
|
|
199
|
+
|
|
200
|
+
for (let attempt = 0; ; attempt++) {
|
|
201
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
202
|
+
const requestUrl = typeof url === "function" ? url(attempt) : url;
|
|
203
|
+
// `timeout` is destructured out of `baseInit`, so forward it to the underlying
|
|
204
|
+
// fetch on the no-`prepareInit` path too. Without this, callers that pass
|
|
205
|
+
// `timeout: false` (every streaming provider, to disable Bun's native ~300s
|
|
206
|
+
// fetch ceiling in favor of their own first-event/idle watchdog) had it
|
|
207
|
+
// silently dropped, so long-running streams were killed at ~300s (issue #602).
|
|
208
|
+
// Only forward when the caller actually set `timeout`, so callers that never
|
|
209
|
+
// set it keep Bun's default ceiling.
|
|
210
|
+
const init = prepareInit
|
|
211
|
+
? mergeInit(baseInit, await prepareInit(attempt), timeout)
|
|
212
|
+
: "timeout" in options
|
|
213
|
+
? ({ ...baseInit, timeout } as unknown as RequestInit)
|
|
214
|
+
: baseInit;
|
|
215
|
+
|
|
216
|
+
let response: Response;
|
|
217
|
+
try {
|
|
218
|
+
response = await fetchImpl(requestUrl, init);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
221
|
+
const wrapped = wrapNetworkError(error);
|
|
222
|
+
if (attempt + 1 >= maxAttempts) throw wrapped;
|
|
223
|
+
await scheduler.wait(resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), { signal });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!isRetryableStatus(response.status)) return response;
|
|
228
|
+
if (attempt + 1 >= maxAttempts) return response;
|
|
229
|
+
|
|
230
|
+
const retryBody = await response.clone().text();
|
|
231
|
+
if (shouldRetryResponse && !(await shouldRetryResponse(response, retryBody, attempt))) return response;
|
|
232
|
+
|
|
233
|
+
const hint = extractRetryHint(response, retryBody);
|
|
234
|
+
if (hint !== undefined && hint > maxDelayMs) return response;
|
|
235
|
+
|
|
236
|
+
const delayMs = Math.min(hint ?? resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), maxDelayMs);
|
|
237
|
+
await scheduler.wait(delayMs, { signal });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function mergeInit(base: RequestInit, overlay: RequestInit, timeout: number | false): RequestInit {
|
|
242
|
+
const merged = { ...base, ...overlay, timeout } as unknown as RequestInit;
|
|
243
|
+
if (base.headers || overlay.headers) {
|
|
244
|
+
const baseHeaders = new Headers(base.headers ?? undefined);
|
|
245
|
+
const overlayHeaders = new Headers(overlay.headers ?? undefined);
|
|
246
|
+
overlayHeaders.forEach((value, key) => {
|
|
247
|
+
baseHeaders.set(key, value);
|
|
248
|
+
});
|
|
249
|
+
merged.headers = baseHeaders;
|
|
250
|
+
}
|
|
251
|
+
return merged;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function wrapNetworkError(error: unknown): Error {
|
|
255
|
+
if (error instanceof Error) {
|
|
256
|
+
if (error.name === "AbortError" || error.message === "Request was aborted") {
|
|
257
|
+
return new Error("Request was aborted");
|
|
258
|
+
}
|
|
259
|
+
if (error.message === "fetch failed" && error.cause instanceof Error) {
|
|
260
|
+
return new Error(`Network error: ${error.cause.message}`);
|
|
261
|
+
}
|
|
262
|
+
return error;
|
|
263
|
+
}
|
|
264
|
+
return new Error(String(error));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function resolveDefaultDelay(
|
|
268
|
+
option: FetchWithRetryOptions["defaultDelayMs"],
|
|
269
|
+
attempt: number,
|
|
270
|
+
maxDelayMs: number,
|
|
271
|
+
): number {
|
|
272
|
+
if (option === undefined) return Math.min(500 * 2 ** attempt, maxDelayMs);
|
|
273
|
+
if (typeof option === "number") return Math.min(option, maxDelayMs);
|
|
274
|
+
if (typeof option === "function") return Math.min(option(attempt), maxDelayMs);
|
|
275
|
+
return Math.min(option[Math.min(attempt, option.length - 1)] ?? 0, maxDelayMs);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Inspect an arbitrary error value (or its `cause` chain, up to depth 2) for an
|
|
280
|
+
* HTTP status code. Reads `status`, `statusCode`, and `response.status` fields,
|
|
281
|
+
* coerces string values, and falls back to scanning the error message for
|
|
282
|
+
* common patterns like `Error: 401`, `error (429)`, or `HTTP 503`.
|
|
283
|
+
*/
|
|
284
|
+
export function extractHttpStatusFromError(error: unknown): number | undefined {
|
|
285
|
+
return extractHttpStatusFromErrorInternal(error, 0);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
type HttpErrorLike = {
|
|
289
|
+
message?: string;
|
|
290
|
+
name?: string;
|
|
291
|
+
status?: number | string;
|
|
292
|
+
statusCode?: number | string;
|
|
293
|
+
response?: { status?: number | string };
|
|
294
|
+
cause?: unknown;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
function extractHttpStatusFromErrorInternal(error: unknown, depth: number): number | undefined {
|
|
298
|
+
if (!error || typeof error !== "object" || depth > 2) return undefined;
|
|
299
|
+
const info = error as HttpErrorLike;
|
|
300
|
+
const rawStatus = info.status ?? info.statusCode ?? info.response?.status;
|
|
301
|
+
|
|
302
|
+
let status: number | undefined;
|
|
303
|
+
if (typeof rawStatus === "number" && Number.isFinite(rawStatus)) {
|
|
304
|
+
status = rawStatus;
|
|
305
|
+
} else if (typeof rawStatus === "string") {
|
|
306
|
+
const parsed = Number(rawStatus);
|
|
307
|
+
if (Number.isFinite(parsed)) status = parsed;
|
|
308
|
+
}
|
|
309
|
+
if (status !== undefined && status >= 100 && status <= 599) return status;
|
|
310
|
+
|
|
311
|
+
if (info.message) {
|
|
312
|
+
const extracted = extractStatusFromMessage(info.message);
|
|
313
|
+
if (extracted !== undefined) return extracted;
|
|
314
|
+
}
|
|
315
|
+
if (info.cause) return extractHttpStatusFromErrorInternal(info.cause, depth + 1);
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const STATUS_MESSAGE_PATTERNS = [
|
|
320
|
+
/\berror\s*[:=]\s*(\d{3})\b/i,
|
|
321
|
+
/error\s*\((\d{3})\)/i,
|
|
322
|
+
/status\s*[:=]?\s*(\d{3})/i,
|
|
323
|
+
/\bhttp\s*(\d{3})\b/i,
|
|
324
|
+
/\b(\d{3})\s*(?:status|error)\b/i,
|
|
325
|
+
] as const;
|
|
326
|
+
|
|
327
|
+
function extractStatusFromMessage(message: string): number | undefined {
|
|
328
|
+
for (const pattern of STATUS_MESSAGE_PATTERNS) {
|
|
329
|
+
const match = pattern.exec(message);
|
|
330
|
+
if (!match) continue;
|
|
331
|
+
const value = Number(match[1]);
|
|
332
|
+
if (Number.isFinite(value) && value >= 100 && value <= 599) return value;
|
|
333
|
+
}
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* `true` if the given HTTP status code is one we treat as transient: 408
|
|
339
|
+
* (Request Timeout), 429 (Too Many Requests), or any 5xx (server error).
|
|
340
|
+
*/
|
|
341
|
+
export function isRetryableStatus(status: number): boolean {
|
|
342
|
+
return status >= 500 || status === 408 || status === 429;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* `true` if the message describes an unexpected socket closure — Bun and some
|
|
347
|
+
* proxies surface these for any HTTP/2 stream reset.
|
|
348
|
+
*/
|
|
349
|
+
export function isUnexpectedSocketCloseMessage(message: string): boolean {
|
|
350
|
+
return /\b(?:the\s+)?socket connection (?:was )?closed unexpectedly\b/i.test(message);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const TRANSIENT_MESSAGE_PATTERN =
|
|
354
|
+
/overloaded|rate.?limit|too many requests|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|network error|stream stall|other side closed|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)/i;
|
|
355
|
+
|
|
356
|
+
const VALIDATION_MESSAGE_PATTERN =
|
|
357
|
+
/invalid|validation|bad request|unsupported|schema|missing required|not found|unauthorized|forbidden/i;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Identify errors that should be retried: aborts/timeouts in the error name or
|
|
361
|
+
* message, retryable HTTP statuses (see `isRetryableStatus`), unexpected socket
|
|
362
|
+
* closes, and the standard transient phrases. 4xx statuses other than 408/429
|
|
363
|
+
* and validation-shaped messages short-circuit to `false`.
|
|
364
|
+
*/
|
|
365
|
+
export function isRetryableError(error: unknown): boolean {
|
|
366
|
+
const info = error as { message?: string; name?: string } | null;
|
|
367
|
+
const message = info?.message ?? "";
|
|
368
|
+
const name = info?.name ?? "";
|
|
369
|
+
if (name === "AbortError" || /timeout|timed out|aborted/i.test(message)) return true;
|
|
370
|
+
|
|
371
|
+
const status = extractHttpStatusFromError(error);
|
|
372
|
+
if (status !== undefined) {
|
|
373
|
+
if (isRetryableStatus(status)) return true;
|
|
374
|
+
if (status >= 400 && status < 500) return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (VALIDATION_MESSAGE_PATTERN.test(message)) return false;
|
|
378
|
+
return isUnexpectedSocketCloseMessage(message) || TRANSIENT_MESSAGE_PATTERN.test(message);
|
|
379
|
+
}
|
package/src/file-lock.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process advisory lock for packages that serialize access to an
|
|
3
|
+
* on-disk resource. The native handle is process-owned and automatically
|
|
4
|
+
* released on exit: Linux uses abstract Unix sockets, Windows uses named
|
|
5
|
+
* mutexes, and other Unix platforms use `flock(2)` on `${filePath}.lock`.
|
|
6
|
+
*/
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { FileLock as NativeFileLock } from "@linxiraos/pi-natives";
|
|
9
|
+
|
|
10
|
+
/** Controls bounded waiting when an advisory file lock is contended. */
|
|
11
|
+
export interface FileLockOptions {
|
|
12
|
+
/** Maximum acquisition attempts, including the initial attempt. */
|
|
13
|
+
retries?: number;
|
|
14
|
+
/** Delay between acquisition attempts. */
|
|
15
|
+
retryDelayMs?: number;
|
|
16
|
+
/** Maximum age of the lock before it is considered stale and can be broken. */
|
|
17
|
+
staleMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_OPTIONS: Required<FileLockOptions> = {
|
|
21
|
+
retries: 50,
|
|
22
|
+
retryDelayMs: 100,
|
|
23
|
+
staleMs: 0,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function getLockPath(filePath: string): string {
|
|
27
|
+
return `${path.resolve(filePath)}.lock`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function tryAcquireLock(lockPath: string): NativeFileLock | null {
|
|
31
|
+
const lock = NativeFileLock.tryAcquire(lockPath);
|
|
32
|
+
return lock.acquired ? lock : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<NativeFileLock> {
|
|
36
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
37
|
+
const lockPath = getLockPath(filePath);
|
|
38
|
+
|
|
39
|
+
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
40
|
+
const lock = tryAcquireLock(lockPath);
|
|
41
|
+
if (lock) return lock;
|
|
42
|
+
if (attempt + 1 < opts.retries) await Bun.sleep(opts.retryDelayMs);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
49
|
+
export async function withFileLock<T>(
|
|
50
|
+
filePath: string,
|
|
51
|
+
fn: () => Promise<T>,
|
|
52
|
+
options: FileLockOptions = {},
|
|
53
|
+
): Promise<T> {
|
|
54
|
+
const lock = await acquireLock(filePath, options);
|
|
55
|
+
try {
|
|
56
|
+
return await fn();
|
|
57
|
+
} finally {
|
|
58
|
+
lock.release();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Test-only acquisition handle for forcing ownership handoffs. This is not
|
|
64
|
+
* part of the supported package API.
|
|
65
|
+
*/
|
|
66
|
+
export const __internalsForTesting = {
|
|
67
|
+
tryAcquireLock,
|
|
68
|
+
getLockPath,
|
|
69
|
+
};
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const SEC = 1_000;
|
|
2
|
+
const MIN = 60 * SEC;
|
|
3
|
+
const HOUR = 60 * MIN;
|
|
4
|
+
const DAY = 24 * HOUR;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format a duration in milliseconds to a short human-readable string.
|
|
8
|
+
* Examples: "123ms", "1.5s", "30m15s", "2h30m", "3d2h"
|
|
9
|
+
*/
|
|
10
|
+
export function formatDuration(ms: number): string {
|
|
11
|
+
if (!Number.isFinite(ms) || ms <= 0) return "0ms";
|
|
12
|
+
if (ms < SEC) return `${ms}ms`;
|
|
13
|
+
if (ms < MIN) return `${(ms / SEC).toFixed(1)}s`;
|
|
14
|
+
if (ms < HOUR) {
|
|
15
|
+
const mins = Math.floor(ms / MIN);
|
|
16
|
+
const secs = Math.floor((ms % MIN) / SEC);
|
|
17
|
+
return secs > 0 ? `${mins}m${secs}s` : `${mins}m`;
|
|
18
|
+
}
|
|
19
|
+
if (ms < DAY) {
|
|
20
|
+
const hours = Math.floor(ms / HOUR);
|
|
21
|
+
const mins = Math.floor((ms % HOUR) / MIN);
|
|
22
|
+
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
|
23
|
+
}
|
|
24
|
+
const days = Math.floor(ms / DAY);
|
|
25
|
+
const hours = Math.floor((ms % DAY) / HOUR);
|
|
26
|
+
return hours > 0 ? `${days}d${hours}h` : `${days}d`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format a number with K/M/B suffix for compact display.
|
|
31
|
+
* Uses 1 decimal for small leading digits when non-zero, rounded otherwise.
|
|
32
|
+
* Examples: "999", "1K", "1.5K", "25K", "1M", "1.5M", "25M", "1.5B"
|
|
33
|
+
*/
|
|
34
|
+
export function formatNumber(n: number): string {
|
|
35
|
+
if (n < 1_000) return n.toString();
|
|
36
|
+
if (n < 10_000) return `${trim1(n / 1_000)}K`;
|
|
37
|
+
if (n < 1_000_000) return `${Math.round(n / 1_000)}K`;
|
|
38
|
+
if (n < 10_000_000) return `${trim1(n / 1_000_000)}M`;
|
|
39
|
+
if (n < 1_000_000_000) return `${Math.round(n / 1_000_000)}M`;
|
|
40
|
+
if (n < 10_000_000_000) return `${trim1(n / 1_000_000_000)}B`;
|
|
41
|
+
return `${Math.round(n / 1_000_000_000)}B`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Format with up to 1 decimal place, dropping trailing `.0`. */
|
|
45
|
+
function trim1(n: number): string {
|
|
46
|
+
const s = n.toFixed(1);
|
|
47
|
+
return s.endsWith(".0") ? s.slice(0, -2) : s;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format a byte count to a human-readable string.
|
|
52
|
+
* Examples: "512B", "1.5KB", "2.3MB", "1.2GB"
|
|
53
|
+
*/
|
|
54
|
+
export function formatBytes(bytes: number): string {
|
|
55
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
56
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
57
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
58
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Truncate a string to maxLen characters, appending an ellipsis if truncated.
|
|
63
|
+
* For display-width-aware truncation (terminals), use truncateToWidth from @linxiraos/pi-tui.
|
|
64
|
+
*/
|
|
65
|
+
export function truncate(str: string, maxLen: number, ellipsis = "…"): string {
|
|
66
|
+
if (str.length <= maxLen) return str;
|
|
67
|
+
const sliceLen = Math.max(0, maxLen - ellipsis.length);
|
|
68
|
+
return `${str.slice(0, sliceLen)}${ellipsis}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Format count with pluralized label (e.g., "3 files", "1 error").
|
|
73
|
+
*/
|
|
74
|
+
export function formatCount(label: string, count: number): string {
|
|
75
|
+
const safeCount = Number.isFinite(count) ? count : 0;
|
|
76
|
+
return `${safeCount} ${pluralize(label, safeCount)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format age from seconds to human-readable string.
|
|
81
|
+
*/
|
|
82
|
+
export function formatAge(ageSeconds: number | null | undefined): string {
|
|
83
|
+
if (!ageSeconds) return "";
|
|
84
|
+
const mins = Math.floor(ageSeconds / 60);
|
|
85
|
+
const hours = Math.floor(mins / 60);
|
|
86
|
+
const days = Math.floor(hours / 24);
|
|
87
|
+
const weeks = Math.floor(days / 7);
|
|
88
|
+
const months = Math.floor(days / 30);
|
|
89
|
+
|
|
90
|
+
if (months > 0) return `${months}mo ago`;
|
|
91
|
+
if (weeks > 0) return `${weeks}w ago`;
|
|
92
|
+
if (days > 0) return `${days}d ago`;
|
|
93
|
+
if (hours > 0) return `${hours}h ago`;
|
|
94
|
+
if (mins > 0) return `${mins}m ago`;
|
|
95
|
+
return "just now";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pluralize a label based on the count.
|
|
100
|
+
*/
|
|
101
|
+
export function pluralize(label: string, count: number): string {
|
|
102
|
+
if (count === 1) return label;
|
|
103
|
+
if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`;
|
|
104
|
+
if (/[^aeiou]y$/i.test(label)) return `${label.slice(0, -1)}ies`;
|
|
105
|
+
return `${label}s`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Format a ratio as a percentage.
|
|
110
|
+
*/
|
|
111
|
+
export function formatPercent(ratio: number): string {
|
|
112
|
+
return `${(ratio * 100).toFixed(1)}%`;
|
|
113
|
+
}
|