octocode-ai-shared 5.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/package.json +42 -0
- package/src/filesystem.ts +236 -0
- package/src/global.ts +84 -0
- package/src/types.d.ts +46 -0
- package/src/util/array.ts +10 -0
- package/src/util/binary.ts +41 -0
- package/src/util/effect-flock.ts +283 -0
- package/src/util/encode.ts +51 -0
- package/src/util/error.ts +60 -0
- package/src/util/flock.ts +358 -0
- package/src/util/fn.ts +11 -0
- package/src/util/glob.ts +34 -0
- package/src/util/hash.ts +7 -0
- package/src/util/identifier.ts +53 -0
- package/src/util/iife.ts +3 -0
- package/src/util/lazy.ts +11 -0
- package/src/util/module.ts +10 -0
- package/src/util/path.ts +37 -0
- package/src/util/retry.ts +42 -0
- package/src/util/slug.ts +74 -0
- package/test/filesystem/filesystem.test.ts +338 -0
- package/test/fixture/effect-flock-worker.ts +63 -0
- package/test/fixture/flock-worker.ts +72 -0
- package/test/global.test.ts +54 -0
- package/test/lib/effect.ts +53 -0
- package/test/util/effect-flock.test.ts +389 -0
- package/test/util/flock.test.ts +426 -0
- package/test/util/identifier.test.ts +30 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import os from "os"
|
|
3
|
+
import { randomBytes, randomUUID } from "crypto"
|
|
4
|
+
import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises"
|
|
5
|
+
import { Hash } from "./hash"
|
|
6
|
+
import { Effect } from "effect"
|
|
7
|
+
|
|
8
|
+
export type FlockGlobal = {
|
|
9
|
+
state: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export namespace Flock {
|
|
13
|
+
let global: FlockGlobal | undefined
|
|
14
|
+
|
|
15
|
+
export function setGlobal(g: FlockGlobal) {
|
|
16
|
+
global = g
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const root = () => {
|
|
20
|
+
if (!global) throw new Error("Flock global not set")
|
|
21
|
+
return path.join(global.state, "locks")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Defaults for callers that do not provide timing options.
|
|
25
|
+
const defaultOpts = {
|
|
26
|
+
staleMs: 60_000,
|
|
27
|
+
timeoutMs: 5 * 60_000,
|
|
28
|
+
baseDelayMs: 100,
|
|
29
|
+
maxDelayMs: 2_000,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WaitEvent {
|
|
33
|
+
key: string
|
|
34
|
+
attempt: number
|
|
35
|
+
delay: number
|
|
36
|
+
waited: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type Wait = (input: WaitEvent) => void | Promise<void>
|
|
40
|
+
|
|
41
|
+
export interface Options {
|
|
42
|
+
dir?: string
|
|
43
|
+
signal?: AbortSignal
|
|
44
|
+
staleMs?: number
|
|
45
|
+
timeoutMs?: number
|
|
46
|
+
baseDelayMs?: number
|
|
47
|
+
maxDelayMs?: number
|
|
48
|
+
onWait?: Wait
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Opts = {
|
|
52
|
+
staleMs: number
|
|
53
|
+
timeoutMs: number
|
|
54
|
+
baseDelayMs: number
|
|
55
|
+
maxDelayMs: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Owned = {
|
|
59
|
+
acquired: true
|
|
60
|
+
startHeartbeat: (intervalMs?: number) => void
|
|
61
|
+
release: () => Promise<void>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Lease {
|
|
65
|
+
release: () => Promise<void>
|
|
66
|
+
[Symbol.asyncDispose]: () => Promise<void>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function code(err: unknown) {
|
|
70
|
+
if (typeof err !== "object" || err === null || !("code" in err)) return
|
|
71
|
+
const value = err.code
|
|
72
|
+
if (typeof value !== "string") return
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sleep(ms: number, signal?: AbortSignal) {
|
|
77
|
+
return new Promise<void>((resolve, reject) => {
|
|
78
|
+
if (signal?.aborted) {
|
|
79
|
+
reject(signal.reason ?? new Error("Aborted"))
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let timer: NodeJS.Timeout | undefined
|
|
84
|
+
|
|
85
|
+
const done = () => {
|
|
86
|
+
signal?.removeEventListener("abort", abort)
|
|
87
|
+
resolve()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const abort = () => {
|
|
91
|
+
if (timer) {
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
}
|
|
94
|
+
signal?.removeEventListener("abort", abort)
|
|
95
|
+
reject(signal?.reason ?? new Error("Aborted"))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
signal?.addEventListener("abort", abort, { once: true })
|
|
99
|
+
timer = setTimeout(done, ms)
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function jitter(ms: number) {
|
|
104
|
+
const j = Math.floor(ms * 0.3)
|
|
105
|
+
const d = Math.floor(Math.random() * (2 * j + 1)) - j
|
|
106
|
+
return Math.max(0, ms + d)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function mono() {
|
|
110
|
+
return performance.now()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function wall() {
|
|
114
|
+
return performance.timeOrigin + mono()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function stats(file: string) {
|
|
118
|
+
try {
|
|
119
|
+
return await stat(file)
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const errCode = code(err)
|
|
122
|
+
if (errCode === "ENOENT" || errCode === "ENOTDIR") return
|
|
123
|
+
throw err
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) {
|
|
128
|
+
// Stale detection allows automatic recovery after crashed owners.
|
|
129
|
+
const now = wall()
|
|
130
|
+
const heartbeat = await stats(heartbeatPath)
|
|
131
|
+
if (heartbeat) {
|
|
132
|
+
return now - heartbeat.mtimeMs > staleMs
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const meta = await stats(metaPath)
|
|
136
|
+
if (meta) {
|
|
137
|
+
return now - meta.mtimeMs > staleMs
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const dir = await stats(lockDir)
|
|
141
|
+
if (!dir) {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return now - dir.mtimeMs > staleMs
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function tryAcquireLockDir(lockDir: string, opts: Opts): Promise<Owned | { acquired: false }> {
|
|
149
|
+
const token = randomUUID?.() ?? randomBytes(16).toString("hex")
|
|
150
|
+
const metaPath = path.join(lockDir, "meta.json")
|
|
151
|
+
const heartbeatPath = path.join(lockDir, "heartbeat")
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await mkdir(lockDir, { mode: 0o700 })
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (code(err) !== "EEXIST") {
|
|
157
|
+
throw err
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
|
|
161
|
+
return { acquired: false }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const breakerPath = lockDir + ".breaker"
|
|
165
|
+
try {
|
|
166
|
+
await mkdir(breakerPath, { mode: 0o700 })
|
|
167
|
+
} catch (claimErr) {
|
|
168
|
+
const errCode = code(claimErr)
|
|
169
|
+
if (errCode === "EEXIST") {
|
|
170
|
+
const breaker = await stats(breakerPath)
|
|
171
|
+
if (breaker && wall() - breaker.mtimeMs > opts.staleMs) {
|
|
172
|
+
await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
|
|
173
|
+
}
|
|
174
|
+
return { acquired: false }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (errCode === "ENOENT" || errCode === "ENOTDIR") {
|
|
178
|
+
return { acquired: false }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
throw claimErr
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
// Breaker ownership ensures only one contender performs stale cleanup.
|
|
186
|
+
if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
|
|
187
|
+
return { acquired: false }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await rm(lockDir, { recursive: true, force: true })
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
await mkdir(lockDir, { mode: 0o700 })
|
|
194
|
+
} catch (retryErr) {
|
|
195
|
+
const errCode = code(retryErr)
|
|
196
|
+
if (errCode === "EEXIST" || errCode === "ENOTEMPTY") {
|
|
197
|
+
return { acquired: false }
|
|
198
|
+
}
|
|
199
|
+
throw retryErr
|
|
200
|
+
}
|
|
201
|
+
} finally {
|
|
202
|
+
await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const meta = {
|
|
207
|
+
token,
|
|
208
|
+
pid: process.pid,
|
|
209
|
+
hostname: os.hostname(),
|
|
210
|
+
createdAt: new Date().toISOString(),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await writeFile(heartbeatPath, "", { flag: "wx" }).catch(async () => {
|
|
214
|
+
await rm(lockDir, { recursive: true, force: true })
|
|
215
|
+
throw new Error("Lock acquired but heartbeat already existed (possible compromise).")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2), { flag: "wx" }).catch(async () => {
|
|
219
|
+
await rm(lockDir, { recursive: true, force: true })
|
|
220
|
+
throw new Error("Lock acquired but meta.json already existed (possible compromise).")
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
let timer: NodeJS.Timeout | undefined
|
|
224
|
+
|
|
225
|
+
const startHeartbeat = (intervalMs = Math.max(100, Math.floor(opts.staleMs / 3))) => {
|
|
226
|
+
if (timer) return
|
|
227
|
+
// Heartbeat prevents long critical sections from being evicted as stale.
|
|
228
|
+
timer = setInterval(() => {
|
|
229
|
+
const t = new Date()
|
|
230
|
+
void utimes(heartbeatPath, t, t).catch(() => undefined)
|
|
231
|
+
}, intervalMs)
|
|
232
|
+
timer.unref?.()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const release = async () => {
|
|
236
|
+
if (timer) {
|
|
237
|
+
clearInterval(timer)
|
|
238
|
+
timer = undefined
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const current = await readFile(metaPath, "utf8")
|
|
242
|
+
.then((raw) => {
|
|
243
|
+
const parsed = JSON.parse(raw)
|
|
244
|
+
if (!parsed || typeof parsed !== "object") return {}
|
|
245
|
+
return {
|
|
246
|
+
token: "token" in parsed && typeof parsed.token === "string" ? parsed.token : undefined,
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
.catch((err) => {
|
|
250
|
+
const errCode = code(err)
|
|
251
|
+
if (errCode === "ENOENT" || errCode === "ENOTDIR") {
|
|
252
|
+
throw new Error("Refusing to release: lock is compromised (metadata missing).")
|
|
253
|
+
}
|
|
254
|
+
if (err instanceof SyntaxError) {
|
|
255
|
+
throw new Error("Refusing to release: lock is compromised (metadata invalid).")
|
|
256
|
+
}
|
|
257
|
+
throw err
|
|
258
|
+
})
|
|
259
|
+
// Token check prevents deleting a lock that was re-acquired by another process.
|
|
260
|
+
if (current.token !== token) {
|
|
261
|
+
throw new Error("Refusing to release: lock token mismatch (not the owner).")
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await rm(lockDir, { recursive: true, force: true })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
acquired: true,
|
|
269
|
+
startHeartbeat,
|
|
270
|
+
release,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function acquireLockDir(
|
|
275
|
+
lockDir: string,
|
|
276
|
+
input: { key: string; onWait?: Wait; signal?: AbortSignal },
|
|
277
|
+
opts: Opts,
|
|
278
|
+
) {
|
|
279
|
+
const stop = mono() + opts.timeoutMs
|
|
280
|
+
let attempt = 0
|
|
281
|
+
let waited = 0
|
|
282
|
+
let delay = opts.baseDelayMs
|
|
283
|
+
|
|
284
|
+
while (true) {
|
|
285
|
+
input.signal?.throwIfAborted()
|
|
286
|
+
|
|
287
|
+
const res = await tryAcquireLockDir(lockDir, opts)
|
|
288
|
+
if (res.acquired) {
|
|
289
|
+
return res
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (mono() > stop) {
|
|
293
|
+
throw new Error(`Timed out waiting for lock: ${input.key}`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
attempt += 1
|
|
297
|
+
const ms = jitter(delay)
|
|
298
|
+
await input.onWait?.({
|
|
299
|
+
key: input.key,
|
|
300
|
+
attempt,
|
|
301
|
+
delay: ms,
|
|
302
|
+
waited,
|
|
303
|
+
})
|
|
304
|
+
await sleep(ms, input.signal)
|
|
305
|
+
waited += ms
|
|
306
|
+
delay = Math.min(opts.maxDelayMs, Math.floor(delay * 1.7))
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function acquire(key: string, input: Options = {}): Promise<Lease> {
|
|
311
|
+
input.signal?.throwIfAborted()
|
|
312
|
+
const cfg: Opts = {
|
|
313
|
+
staleMs: input.staleMs ?? defaultOpts.staleMs,
|
|
314
|
+
timeoutMs: input.timeoutMs ?? defaultOpts.timeoutMs,
|
|
315
|
+
baseDelayMs: input.baseDelayMs ?? defaultOpts.baseDelayMs,
|
|
316
|
+
maxDelayMs: input.maxDelayMs ?? defaultOpts.maxDelayMs,
|
|
317
|
+
}
|
|
318
|
+
const dir = input.dir ?? root()
|
|
319
|
+
|
|
320
|
+
await mkdir(dir, { recursive: true })
|
|
321
|
+
const lockfile = path.join(dir, Hash.fast(key) + ".lock")
|
|
322
|
+
const lock = await acquireLockDir(
|
|
323
|
+
lockfile,
|
|
324
|
+
{
|
|
325
|
+
key,
|
|
326
|
+
onWait: input.onWait,
|
|
327
|
+
signal: input.signal,
|
|
328
|
+
},
|
|
329
|
+
cfg,
|
|
330
|
+
)
|
|
331
|
+
lock.startHeartbeat()
|
|
332
|
+
|
|
333
|
+
const release = () => lock.release()
|
|
334
|
+
return {
|
|
335
|
+
release,
|
|
336
|
+
[Symbol.asyncDispose]() {
|
|
337
|
+
return release()
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function withLock<T>(key: string, fn: () => Promise<T>, input: Options = {}) {
|
|
343
|
+
await using _ = await acquire(key, input)
|
|
344
|
+
input.signal?.throwIfAborted()
|
|
345
|
+
return await fn()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export const effect = Effect.fn("Flock.effect")(function* (key: string, input: Options = {}) {
|
|
349
|
+
return yield* Effect.acquireRelease(
|
|
350
|
+
Effect.promise((signal) => Flock.acquire(key, { ...input, signal })).pipe(
|
|
351
|
+
Effect.withSpan("Flock.acquire", {
|
|
352
|
+
attributes: { key },
|
|
353
|
+
}),
|
|
354
|
+
),
|
|
355
|
+
(lock) => Effect.promise(() => lock.release()).pipe(Effect.withSpan("Flock.release")),
|
|
356
|
+
).pipe(Effect.asVoid)
|
|
357
|
+
})
|
|
358
|
+
}
|
package/src/util/fn.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
|
|
4
|
+
const result = (input: z.infer<T>) => {
|
|
5
|
+
const parsed = schema.parse(input)
|
|
6
|
+
return cb(parsed)
|
|
7
|
+
}
|
|
8
|
+
result.force = (input: z.infer<T>) => cb(input)
|
|
9
|
+
result.schema = schema
|
|
10
|
+
return result
|
|
11
|
+
}
|
package/src/util/glob.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { glob, globSync, type GlobOptions } from "glob"
|
|
2
|
+
import { minimatch } from "minimatch"
|
|
3
|
+
|
|
4
|
+
export namespace Glob {
|
|
5
|
+
export interface Options {
|
|
6
|
+
cwd?: string
|
|
7
|
+
absolute?: boolean
|
|
8
|
+
include?: "file" | "all"
|
|
9
|
+
dot?: boolean
|
|
10
|
+
symlink?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toGlobOptions(options: Options): GlobOptions {
|
|
14
|
+
return {
|
|
15
|
+
cwd: options.cwd,
|
|
16
|
+
absolute: options.absolute,
|
|
17
|
+
dot: options.dot,
|
|
18
|
+
follow: options.symlink ?? false,
|
|
19
|
+
nodir: options.include !== "all",
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function scan(pattern: string, options: Options = {}): Promise<string[]> {
|
|
24
|
+
return glob(pattern, toGlobOptions(options)) as Promise<string[]>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function scanSync(pattern: string, options: Options = {}): string[] {
|
|
28
|
+
return globSync(pattern, toGlobOptions(options)) as string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function match(pattern: string, filepath: string): boolean {
|
|
32
|
+
return minimatch(filepath, pattern, { dot: true })
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/util/hash.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomBytes } from "crypto"
|
|
2
|
+
|
|
3
|
+
export namespace Identifier {
|
|
4
|
+
// Payload is always 26 chars. v1 was 12 hex + 14 base62 (48-bit, wraps
|
|
5
|
+
// every 2^36 ms). v2 is `{g|-}` + 16 hex + 9 base62 so lexicographic order
|
|
6
|
+
// stays compatible with existing v1 ids: `g` > `f`, `-` < `0`.
|
|
7
|
+
const LENGTH = 26
|
|
8
|
+
const TIME_BYTES = 8
|
|
9
|
+
|
|
10
|
+
// State for monotonic ID generation
|
|
11
|
+
let lastTimestamp = 0
|
|
12
|
+
let counter = 0
|
|
13
|
+
|
|
14
|
+
export function ascending() {
|
|
15
|
+
return create(false)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function descending() {
|
|
19
|
+
return create(true)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function randomBase62(length: number): string {
|
|
23
|
+
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
24
|
+
let result = ""
|
|
25
|
+
const bytes = randomBytes(length)
|
|
26
|
+
for (let i = 0; i < length; i++) {
|
|
27
|
+
result += chars[bytes[i] % 62]
|
|
28
|
+
}
|
|
29
|
+
return result
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function create(descending: boolean, timestamp?: number): string {
|
|
33
|
+
const currentTimestamp = timestamp ?? Date.now()
|
|
34
|
+
|
|
35
|
+
if (currentTimestamp !== lastTimestamp) {
|
|
36
|
+
lastTimestamp = currentTimestamp
|
|
37
|
+
counter = 0
|
|
38
|
+
}
|
|
39
|
+
counter++
|
|
40
|
+
|
|
41
|
+
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
|
42
|
+
|
|
43
|
+
now = descending ? ~now : now
|
|
44
|
+
|
|
45
|
+
const timeBytes = Buffer.alloc(TIME_BYTES)
|
|
46
|
+
for (let i = 0; i < TIME_BYTES; i++) {
|
|
47
|
+
timeBytes[i] = Number((now >> BigInt(56 - 8 * i)) & BigInt(0xff))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const mark = descending ? "-" : "g"
|
|
51
|
+
return mark + timeBytes.toString("hex") + randomBase62(LENGTH - 1 - TIME_BYTES * 2)
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/util/iife.ts
ADDED
package/src/util/lazy.ts
ADDED
package/src/util/path.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function getFilename(path: string | undefined) {
|
|
2
|
+
if (!path) return ""
|
|
3
|
+
const trimmed = path.replace(/[/\\]+$/, "")
|
|
4
|
+
const parts = trimmed.split(/[/\\]/)
|
|
5
|
+
return parts[parts.length - 1] ?? ""
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getDirectory(path: string | undefined) {
|
|
9
|
+
if (!path) return ""
|
|
10
|
+
const trimmed = path.replace(/[/\\]+$/, "")
|
|
11
|
+
const parts = trimmed.split(/[/\\]/)
|
|
12
|
+
return parts.slice(0, parts.length - 1).join("/") + "/"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getFileExtension(path: string | undefined) {
|
|
16
|
+
if (!path) return ""
|
|
17
|
+
const parts = path.split(".")
|
|
18
|
+
return parts[parts.length - 1]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) {
|
|
22
|
+
const filename = getFilename(path)
|
|
23
|
+
if (filename.length <= maxLength) return filename
|
|
24
|
+
const lastDot = filename.lastIndexOf(".")
|
|
25
|
+
const ext = lastDot <= 0 ? "" : filename.slice(lastDot)
|
|
26
|
+
const available = maxLength - ext.length - 1 // -1 for ellipsis
|
|
27
|
+
if (available <= 0) return filename.slice(0, maxLength - 1) + "…"
|
|
28
|
+
return filename.slice(0, available) + "…" + ext
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function truncateMiddle(text: string, maxLength: number = 20) {
|
|
32
|
+
if (text.length <= maxLength) return text
|
|
33
|
+
const available = maxLength - 1 // -1 for ellipsis
|
|
34
|
+
const start = Math.ceil(available / 2)
|
|
35
|
+
const end = Math.floor(available / 2)
|
|
36
|
+
return text.slice(0, start) + "…" + text.slice(-end)
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
attempts?: number
|
|
3
|
+
delay?: number
|
|
4
|
+
factor?: number
|
|
5
|
+
maxDelay?: number
|
|
6
|
+
retryIf?: (error: unknown) => boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const TRANSIENT_MESSAGES = [
|
|
10
|
+
"load failed",
|
|
11
|
+
"network connection was lost",
|
|
12
|
+
"network request failed",
|
|
13
|
+
"failed to fetch",
|
|
14
|
+
"econnreset",
|
|
15
|
+
"econnrefused",
|
|
16
|
+
"etimedout",
|
|
17
|
+
"socket hang up",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
function isTransientError(error: unknown): boolean {
|
|
21
|
+
if (!error) return false
|
|
22
|
+
// oxlint-disable-next-line no-base-to-string -- error is unknown, intentional coercion for message matching
|
|
23
|
+
const message = String(error instanceof Error ? error.message : error).toLowerCase()
|
|
24
|
+
return TRANSIENT_MESSAGES.some((m) => message.includes(m))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
|
28
|
+
const { attempts = 3, delay = 500, factor = 2, maxDelay = 10000, retryIf = isTransientError } = options
|
|
29
|
+
|
|
30
|
+
let lastError: unknown
|
|
31
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
32
|
+
try {
|
|
33
|
+
return await fn()
|
|
34
|
+
} catch (error) {
|
|
35
|
+
lastError = error
|
|
36
|
+
if (attempt === attempts - 1 || !retryIf(error)) throw error
|
|
37
|
+
const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay)
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, wait))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw lastError
|
|
42
|
+
}
|
package/src/util/slug.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export namespace Slug {
|
|
2
|
+
const ADJECTIVES = [
|
|
3
|
+
"brave",
|
|
4
|
+
"calm",
|
|
5
|
+
"clever",
|
|
6
|
+
"cosmic",
|
|
7
|
+
"crisp",
|
|
8
|
+
"curious",
|
|
9
|
+
"eager",
|
|
10
|
+
"gentle",
|
|
11
|
+
"glowing",
|
|
12
|
+
"happy",
|
|
13
|
+
"hidden",
|
|
14
|
+
"jolly",
|
|
15
|
+
"kind",
|
|
16
|
+
"lucky",
|
|
17
|
+
"mighty",
|
|
18
|
+
"misty",
|
|
19
|
+
"neon",
|
|
20
|
+
"nimble",
|
|
21
|
+
"playful",
|
|
22
|
+
"proud",
|
|
23
|
+
"quick",
|
|
24
|
+
"quiet",
|
|
25
|
+
"shiny",
|
|
26
|
+
"silent",
|
|
27
|
+
"stellar",
|
|
28
|
+
"sunny",
|
|
29
|
+
"swift",
|
|
30
|
+
"tidy",
|
|
31
|
+
"witty",
|
|
32
|
+
] as const
|
|
33
|
+
|
|
34
|
+
const NOUNS = [
|
|
35
|
+
"cabin",
|
|
36
|
+
"cactus",
|
|
37
|
+
"canyon",
|
|
38
|
+
"circuit",
|
|
39
|
+
"comet",
|
|
40
|
+
"eagle",
|
|
41
|
+
"engine",
|
|
42
|
+
"falcon",
|
|
43
|
+
"forest",
|
|
44
|
+
"garden",
|
|
45
|
+
"harbor",
|
|
46
|
+
"island",
|
|
47
|
+
"knight",
|
|
48
|
+
"lagoon",
|
|
49
|
+
"meadow",
|
|
50
|
+
"moon",
|
|
51
|
+
"mountain",
|
|
52
|
+
"nebula",
|
|
53
|
+
"orchid",
|
|
54
|
+
"otter",
|
|
55
|
+
"panda",
|
|
56
|
+
"pixel",
|
|
57
|
+
"planet",
|
|
58
|
+
"river",
|
|
59
|
+
"rocket",
|
|
60
|
+
"sailor",
|
|
61
|
+
"squid",
|
|
62
|
+
"star",
|
|
63
|
+
"tiger",
|
|
64
|
+
"wizard",
|
|
65
|
+
"wolf",
|
|
66
|
+
] as const
|
|
67
|
+
|
|
68
|
+
export function create() {
|
|
69
|
+
return [
|
|
70
|
+
ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)],
|
|
71
|
+
NOUNS[Math.floor(Math.random() * NOUNS.length)],
|
|
72
|
+
].join("-")
|
|
73
|
+
}
|
|
74
|
+
}
|