@saeeol/core 7.3.1
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 +52 -0
- package/src/cross-spawn-process.ts +273 -0
- package/src/cross-spawn-spawner.ts +505 -0
- package/src/cross-spawn-utils.ts +74 -0
- package/src/effect/logger.ts +73 -0
- package/src/effect/memo-map.ts +3 -0
- package/src/effect/observability.ts +107 -0
- package/src/effect/runtime.ts +21 -0
- package/src/filesystem.ts +262 -0
- package/src/flag/flag.ts +107 -0
- package/src/global.ts +91 -0
- package/src/installation/version.ts +11 -0
- package/src/npm-config.ts +40 -0
- package/src/npm.ts +271 -0
- package/src/saeeol/global.ts +23 -0
- package/src/saeeol/kilocode/global.ts +23 -0
- package/src/saeeol/kilocode/spotlight.ts +23 -0
- package/src/saeeol/spotlight.ts +23 -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 +52 -0
- package/src/util/error.ts +60 -0
- package/src/util/flock.ts +358 -0
- package/src/util/glob.ts +34 -0
- package/src/util/hash.ts +7 -0
- package/src/util/identifier.ts +48 -0
- package/src/util/iife.ts +3 -0
- package/src/util/lazy.ts +11 -0
- package/src/util/log.ts +208 -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/saeeol-process.ts +24 -0
- package/src/util/slug.ts +74 -0
- package/sst-env.d.ts +10 -0
- package/test/effect/cross-spawn-spawner.test.ts +423 -0
- package/test/effect/observability.test.ts +46 -0
- package/test/filesystem/filesystem.test.ts +338 -0
- package/test/fixture/effect-flock-worker.ts +60 -0
- package/test/fixture/flock-worker.ts +72 -0
- package/test/fixture/tmpdir.ts +13 -0
- package/test/global.test.ts +16 -0
- package/test/lib/effect.ts +53 -0
- package/test/npm-config.test.ts +51 -0
- package/test/npm.test.ts +91 -0
- package/test/saeeol/filesystem-containment.test.ts +13 -0
- package/test/saeeol/kilocode/filesystem-containment.test.ts +13 -0
- package/test/util/effect-flock.test.ts +386 -0
- package/test/util/flock.test.ts +426 -0
- package/tsconfig.json +8 -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/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,48 @@
|
|
|
1
|
+
import { randomBytes } from "crypto"
|
|
2
|
+
|
|
3
|
+
export namespace Identifier {
|
|
4
|
+
const LENGTH = 26
|
|
5
|
+
|
|
6
|
+
// State for monotonic ID generation
|
|
7
|
+
let lastTimestamp = 0
|
|
8
|
+
let counter = 0
|
|
9
|
+
|
|
10
|
+
export function ascending() {
|
|
11
|
+
return create(false)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function descending() {
|
|
15
|
+
return create(true)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function randomBase62(length: number): string {
|
|
19
|
+
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
20
|
+
let result = ""
|
|
21
|
+
const bytes = randomBytes(length)
|
|
22
|
+
for (let i = 0; i < length; i++) {
|
|
23
|
+
result += chars[bytes[i] % 62]
|
|
24
|
+
}
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function create(descending: boolean, timestamp?: number): string {
|
|
29
|
+
const currentTimestamp = timestamp ?? Date.now()
|
|
30
|
+
|
|
31
|
+
if (currentTimestamp !== lastTimestamp) {
|
|
32
|
+
lastTimestamp = currentTimestamp
|
|
33
|
+
counter = 0
|
|
34
|
+
}
|
|
35
|
+
counter++
|
|
36
|
+
|
|
37
|
+
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
|
38
|
+
|
|
39
|
+
now = descending ? ~now : now
|
|
40
|
+
|
|
41
|
+
const timeBytes = Buffer.alloc(6)
|
|
42
|
+
for (let i = 0; i < 6; i++) {
|
|
43
|
+
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return timeBytes.toString("hex") + randomBase62(LENGTH - 12)
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/util/iife.ts
ADDED
package/src/util/lazy.ts
ADDED
package/src/util/log.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import { existsSync, writeFileSync } from "fs"
|
|
3
|
+
import fs from "fs/promises"
|
|
4
|
+
import * as Global from "../global"
|
|
5
|
+
import z from "zod"
|
|
6
|
+
import { Glob } from "./glob"
|
|
7
|
+
import { createStream } from "rotating-file-stream"
|
|
8
|
+
|
|
9
|
+
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
|
|
10
|
+
export type Level = z.infer<typeof Level>
|
|
11
|
+
|
|
12
|
+
const levelPriority: Record<Level, number> = {
|
|
13
|
+
DEBUG: 0,
|
|
14
|
+
INFO: 1,
|
|
15
|
+
WARN: 2,
|
|
16
|
+
ERROR: 3,
|
|
17
|
+
}
|
|
18
|
+
const keep = 10
|
|
19
|
+
|
|
20
|
+
let level: Level = "INFO"
|
|
21
|
+
|
|
22
|
+
function shouldLog(input: Level): boolean {
|
|
23
|
+
return levelPriority[input] >= levelPriority[level]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Logger = {
|
|
27
|
+
debug(message?: any, extra?: Record<string, any>): void
|
|
28
|
+
info(message?: any, extra?: Record<string, any>): void
|
|
29
|
+
error(message?: any, extra?: Record<string, any>): void
|
|
30
|
+
warn(message?: any, extra?: Record<string, any>): void
|
|
31
|
+
tag(key: string, value: string): Logger
|
|
32
|
+
clone(): Logger
|
|
33
|
+
time(
|
|
34
|
+
message: string,
|
|
35
|
+
extra?: Record<string, any>,
|
|
36
|
+
): {
|
|
37
|
+
stop(): void
|
|
38
|
+
[Symbol.dispose](): void
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const loggers = new Map<string, Logger>()
|
|
43
|
+
|
|
44
|
+
export const Default = create({ service: "default" })
|
|
45
|
+
|
|
46
|
+
export interface Options {
|
|
47
|
+
print: boolean
|
|
48
|
+
dev?: boolean
|
|
49
|
+
level?: Level
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let logpath = ""
|
|
53
|
+
export function file() {
|
|
54
|
+
return logpath
|
|
55
|
+
}
|
|
56
|
+
let write = (msg: any) => {
|
|
57
|
+
process.stderr.write(msg)
|
|
58
|
+
return msg.length
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function init(options: Options) {
|
|
62
|
+
if (options.level) level = options.level
|
|
63
|
+
void cleanup(Global.Path.log)
|
|
64
|
+
if (options.print) return
|
|
65
|
+
logpath = path.join(
|
|
66
|
+
Global.Path.log,
|
|
67
|
+
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
|
68
|
+
)
|
|
69
|
+
await fs.truncate(logpath).catch(() => {})
|
|
70
|
+
const dir = path.dirname(logpath)
|
|
71
|
+
const stream = createStream(path.basename(logpath), {
|
|
72
|
+
size: "50M",
|
|
73
|
+
maxFiles: 10,
|
|
74
|
+
history: ".log-history",
|
|
75
|
+
path: dir,
|
|
76
|
+
})
|
|
77
|
+
stream.on("rotation", () => {
|
|
78
|
+
if (!existsSync(dir)) return
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// RATIONALE: If current log path was deleted while stream still holds the fd,
|
|
82
|
+
// rotating-file-stream will try to rename a missing path and emit ENOENT.
|
|
83
|
+
writeFileSync(logpath, "", { flag: "wx" })
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (typeof err === "object" && err && "code" in err && err.code === "EEXIST") return
|
|
86
|
+
|
|
87
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
88
|
+
process.stderr.write("log stream warning: " + msg + "\n")
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
stream.on("error", (err: Error) => {
|
|
92
|
+
process.stderr.write("log stream error: " + err.message + "\n")
|
|
93
|
+
})
|
|
94
|
+
stream.on("warning", (err: Error) => {
|
|
95
|
+
process.stderr.write("log stream warning: " + err.message + "\n")
|
|
96
|
+
})
|
|
97
|
+
write = (msg: any) => {
|
|
98
|
+
stream.write(msg)
|
|
99
|
+
return msg.length
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function cleanup(dir: string) {
|
|
104
|
+
const files = (
|
|
105
|
+
await Glob.scan("????-??-??T??????.log", {
|
|
106
|
+
cwd: dir,
|
|
107
|
+
absolute: false,
|
|
108
|
+
include: "file",
|
|
109
|
+
}).catch(() => [])
|
|
110
|
+
)
|
|
111
|
+
.filter((file) => path.basename(file) === file)
|
|
112
|
+
.sort()
|
|
113
|
+
if (files.length <= keep) return
|
|
114
|
+
|
|
115
|
+
const doomed = files.slice(0, -keep)
|
|
116
|
+
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function formatError(error: Error, depth = 0): string {
|
|
120
|
+
const result = error.message
|
|
121
|
+
return error.cause instanceof Error && depth < 10
|
|
122
|
+
? result + " Caused by: " + formatError(error.cause, depth + 1)
|
|
123
|
+
: result
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let last = Date.now()
|
|
127
|
+
export function create(tags?: Record<string, any>) {
|
|
128
|
+
tags = tags || {}
|
|
129
|
+
|
|
130
|
+
const service = tags["service"]
|
|
131
|
+
if (service && typeof service === "string") {
|
|
132
|
+
const cached = loggers.get(service)
|
|
133
|
+
if (cached) {
|
|
134
|
+
return cached
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function build(message: any, extra?: Record<string, any>) {
|
|
139
|
+
const prefix = Object.entries({
|
|
140
|
+
...tags,
|
|
141
|
+
...extra,
|
|
142
|
+
})
|
|
143
|
+
.filter(([_, value]) => value !== undefined && value !== null)
|
|
144
|
+
.map(([key, value]) => {
|
|
145
|
+
const prefix = `${key}=`
|
|
146
|
+
if (value instanceof Error) return prefix + formatError(value)
|
|
147
|
+
if (typeof value === "object") return prefix + JSON.stringify(value)
|
|
148
|
+
return prefix + value
|
|
149
|
+
})
|
|
150
|
+
.join(" ")
|
|
151
|
+
const next = new Date()
|
|
152
|
+
const diff = next.getTime() - last
|
|
153
|
+
last = next.getTime()
|
|
154
|
+
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
|
|
155
|
+
}
|
|
156
|
+
const result: Logger = {
|
|
157
|
+
debug(message?: any, extra?: Record<string, any>) {
|
|
158
|
+
if (shouldLog("DEBUG")) {
|
|
159
|
+
write("DEBUG " + build(message, extra))
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
info(message?: any, extra?: Record<string, any>) {
|
|
163
|
+
if (shouldLog("INFO")) {
|
|
164
|
+
write("INFO " + build(message, extra))
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
error(message?: any, extra?: Record<string, any>) {
|
|
168
|
+
if (shouldLog("ERROR")) {
|
|
169
|
+
write("ERROR " + build(message, extra))
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
warn(message?: any, extra?: Record<string, any>) {
|
|
173
|
+
if (shouldLog("WARN")) {
|
|
174
|
+
write("WARN " + build(message, extra))
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
tag(key: string, value: string) {
|
|
178
|
+
if (tags) tags[key] = value
|
|
179
|
+
return result
|
|
180
|
+
},
|
|
181
|
+
clone() {
|
|
182
|
+
return create({ ...tags })
|
|
183
|
+
},
|
|
184
|
+
time(message: string, extra?: Record<string, any>) {
|
|
185
|
+
const now = Date.now()
|
|
186
|
+
result.info(message, { status: "started", ...extra })
|
|
187
|
+
function stop() {
|
|
188
|
+
result.info(message, {
|
|
189
|
+
status: "completed",
|
|
190
|
+
duration: Date.now() - now,
|
|
191
|
+
...extra,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
stop,
|
|
196
|
+
[Symbol.dispose]() {
|
|
197
|
+
stop()
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (service && typeof service === "string") {
|
|
204
|
+
loggers.set(service, result)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return result
|
|
208
|
+
}
|