@threadedstack/wmd-web 0.0.0-stage → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +2601 -2
  2. package/RELEASE.json +123 -0
  3. package/export-page/coi-sw.js +20 -0
  4. package/export-page/index.html +17 -0
  5. package/export-page/run.css +10 -0
  6. package/export-page/run.js +236 -0
  7. package/guest/arm64/Image +4 -0
  8. package/guest/arm64/Image-4k +4 -0
  9. package/guest/arm64/initramfs.cpio.gz +0 -0
  10. package/guest/arm64/vm.wasm +0 -0
  11. package/package.json +36 -4
  12. package/page/sw.js +381 -0
  13. package/relay/control.ts +139 -0
  14. package/relay/engine.ts +787 -0
  15. package/relay/hosted.ts +660 -0
  16. package/relay/keys.ts +53 -0
  17. package/relay/local-tls.ts +121 -0
  18. package/relay/lock.ts +81 -0
  19. package/relay/logfile.ts +138 -0
  20. package/relay/main.ts +642 -0
  21. package/relay/policy.ts +241 -0
  22. package/relay/relay.ts +266 -0
  23. package/relay/token.ts +116 -0
  24. package/relay/v1.ts +107 -0
  25. package/relay/wisp.ts +615 -0
  26. package/src/base.js +325 -0
  27. package/src/boot.js +2037 -0
  28. package/src/calib-worker.js +40 -0
  29. package/src/fs/9p/codec.js +303 -0
  30. package/src/fs/9p/server.js +521 -0
  31. package/src/fs/errno.js +43 -0
  32. package/src/fs/graph.js +380 -0
  33. package/src/fs/overlay.js +1018 -0
  34. package/src/fs/pack-write.js +165 -0
  35. package/src/fs/pack.js +427 -0
  36. package/src/fs/seed.js +22 -0
  37. package/src/fs/svc-snapshot.js +65 -0
  38. package/src/fs/upper-pack.js +112 -0
  39. package/src/host/base-cache.js +195 -0
  40. package/src/host/base-file.js +328 -0
  41. package/src/host/base-io.js +93 -0
  42. package/src/host/boot-events.js +209 -0
  43. package/src/host/chan-service.js +300 -0
  44. package/src/host/chan-services.js +689 -0
  45. package/src/host/chan.js +655 -0
  46. package/src/host/ckpt-file.js +395 -0
  47. package/src/host/ckpt-io.js +207 -0
  48. package/src/host/ckpt-point.js +160 -0
  49. package/src/host/ckpt-save.js +64 -0
  50. package/src/host/ckpt.js +434 -0
  51. package/src/host/clock.js +111 -0
  52. package/src/host/ctl.js +778 -0
  53. package/src/host/errno.js +18 -0
  54. package/src/host/fs.js +167 -0
  55. package/src/host/gcnudge.js +83 -0
  56. package/src/host/i64shim.js +81 -0
  57. package/src/host/jit-cache.js +341 -0
  58. package/src/host/jit-hub-core.js +208 -0
  59. package/src/host/jit-hub.js +93 -0
  60. package/src/host/jit-snap.js +226 -0
  61. package/src/host/jit.js +1903 -0
  62. package/src/host/layout.js +100 -0
  63. package/src/host/monitor.js +110 -0
  64. package/src/host/p9mount.js +27 -0
  65. package/src/host/ram-mem.js +125 -0
  66. package/src/host/stdio.js +143 -0
  67. package/src/host/threads.js +109 -0
  68. package/src/host/wasi.js +335 -0
  69. package/src/host/wmd-imports.js +71 -0
  70. package/src/host/yamux.js +387 -0
  71. package/src/image-files.js +154 -0
  72. package/src/net/dhcp-dns.js +126 -0
  73. package/src/net/net-worker.js +172 -0
  74. package/src/net/packet.js +227 -0
  75. package/src/net/peer.js +230 -0
  76. package/src/net/proto.js +189 -0
  77. package/src/net/relay-client.js +226 -0
  78. package/src/net/stack.js +867 -0
  79. package/src/net/switch.js +160 -0
  80. package/src/net/wisp-client.js +570 -0
  81. package/src/net/wisp-transport.js +196 -0
  82. package/src/net/wisp.js +511 -0
  83. package/src/registry/client.js +176 -0
  84. package/src/registry/converter.js +68 -0
  85. package/src/registry/hub.js +221 -0
  86. package/src/registry/oci.js +76 -0
  87. package/src/registry/pack-worker.js +70 -0
  88. package/src/registry/pull.js +258 -0
  89. package/src/registry/ref.js +110 -0
  90. package/src/registry/wasi-mem.js +355 -0
  91. package/src/worker.js +315 -0
  92. package/wmdpack.wasm +0 -0
package/relay/keys.ts ADDED
@@ -0,0 +1,53 @@
1
+ // W59: optional owner keys for a standalone relay (off by default; the default needs no key).
2
+ // A key is an id and a secret. The relay stores only an scrypt hash of the secret:
3
+ //
4
+ // scrypt:<N>:<r>:<p>:<salt base64url>:<hash base64url>
5
+ //
6
+ // and a client presents the key with Wisp v2 password auth (username = id, password = secret).
7
+
8
+ import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
9
+
10
+ export type OwnerKey = {
11
+ id: string
12
+ hash: string
13
+ origins?: string[] // page origins this key may be used from (any when unset)
14
+ allowHosts?: string[] // replaces the relay's host allowlist for this key's sessions
15
+ rateDown?: number // bandwidth shared by every session of this key
16
+ rateUp?: number
17
+ }
18
+
19
+ const N = 1 << 14
20
+ const R = 8
21
+ const P = 1
22
+ const LEN = 32
23
+
24
+ function scryptAsync(secret: string, salt: Buffer, n: number, r: number, p: number): Promise<Buffer> {
25
+ return new Promise((res, rej) =>
26
+ scrypt(secret, salt, LEN, { N: n, r, p, maxmem: 256 * n * r + (1 << 20) }, (e, k) => (e ? rej(e) : res(k))),
27
+ )
28
+ }
29
+
30
+ export async function hashSecret(secret: string): Promise<string> {
31
+ const salt = randomBytes(16)
32
+ const k = await scryptAsync(secret, salt, N, R, P)
33
+ return `scrypt:${N}:${R}:${P}:${salt.toString('base64url')}:${k.toString('base64url')}`
34
+ }
35
+
36
+ /** True when `secret` matches the stored hash. Constant time in the comparison; a malformed hash is false. */
37
+ export async function verifySecret(secret: string, stored: string): Promise<boolean> {
38
+ const f = stored.split(':')
39
+ if (f.length !== 6 || f[0] !== 'scrypt') return false
40
+ const [n, r, p] = f.slice(1, 4).map(Number)
41
+ if (!(n >= 2 && n <= 1 << 20 && (n & (n - 1)) === 0 && r >= 1 && r <= 32 && p >= 1 && p <= 16)) return false
42
+ const salt = Buffer.from(f[4], 'base64url')
43
+ const want = Buffer.from(f[5], 'base64url')
44
+ if (want.length !== LEN) return false
45
+ const got = await scryptAsync(secret, salt, n, r, p)
46
+ return timingSafeEqual(got, want)
47
+ }
48
+
49
+ /** A new random key: the secret is shown once, the hash is kept. */
50
+ export async function newKey(id: string): Promise<{ id: string; secret: string; hash: string }> {
51
+ const secret = randomBytes(24).toString('base64url')
52
+ return { id, secret, hash: await hashSecret(secret) }
53
+ }
@@ -0,0 +1,121 @@
1
+ // W59 S4: `wmd-relay --local-tls`. WebKit and Safari block ws:// to loopback from https pages
2
+ // (S0), so a Safari reader needs wss://localhost. This makes one self-signed leaf certificate
3
+ // for localhost, 127.0.0.1 and ::1 (CA:FALSE, serverAuth only: it cannot sign anything else, plan
4
+ // §7 A10) in ~/.wmd/relay/ (0700, key 0600), reuses it while it is valid, and gives the command
5
+ // the person runs to trust it. wmd never touches a keychain itself.
6
+
7
+ import { X509Certificate, createPrivateKey, generateKeyPairSync } from 'node:crypto'
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { breakIfStale, release, tryTake } from './lock.ts'
12
+
13
+ export const DAYS = 800 // macOS trusts TLS server certificates of at most 825 days
14
+ const RENEW_MS = 30 * 86400_000 // a new one when less than this is left
15
+
16
+ export type LocalTls = { cert: string; key: string; certPath: string; keyPath: string; made: boolean; trust: string[] }
17
+
18
+ export function relayDir(home = homedir()): string {
19
+ return join(home, '.wmd', 'relay')
20
+ }
21
+
22
+ /** One process at a time makes or reads the pair (relays started together on a fresh home raced on the temp names, and could leave a key and a certificate that do not belong together). mkdir is the atomic step. */
23
+ function withDirLock<T>(lock: string, fn: () => T): T {
24
+ const t0 = Date.now()
25
+ while (!tryTake(lock, 'dir')) {
26
+ breakIfStale(lock, 'dir') // left by a killed process
27
+ if (Date.now() - t0 > 20_000) throw new Error(`${lock} is held by another wmd-relay; if none is running, delete it`)
28
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)
29
+ }
30
+ try {
31
+ return fn()
32
+ } finally {
33
+ release(lock)
34
+ }
35
+ }
36
+
37
+ export function localTls(dir = relayDir(), openssl = findOpenssl()): LocalTls {
38
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
39
+ chmodSync(dir, 0o700)
40
+ return withDirLock(join(dir, 'local-tls.lock'), () => makeOrRead(dir, openssl))
41
+ }
42
+
43
+ function makeOrRead(dir: string, openssl: string | null): LocalTls {
44
+ const certPath = join(dir, 'local-tls.crt')
45
+ const keyPath = join(dir, 'local-tls.key')
46
+ let made = false
47
+ if (!usable(certPath, keyPath)) {
48
+ if (!openssl) throw new Error('--local-tls needs openssl to make the certificate, and none was found on PATH or in /usr/bin')
49
+ const tmpC = `${certPath}.new`
50
+ const tmpK = `${keyPath}.new`
51
+ const conf = join(dir, 'openssl.cnf.new')
52
+ try {
53
+ // A config of our own: the system openssl on macOS is LibreSSL 3.3, which refuses `-subj` without a
54
+ // [req] distinguished_name, and a missing config file is not an option for it (OPENSSL_CONF=/dev/null failed there).
55
+ writeFileSync(
56
+ conf,
57
+ [
58
+ '[req]', 'distinguished_name = dn', 'prompt = no', 'x509_extensions = ext', '[dn]', 'CN = wmd-relay local',
59
+ '[ext]', 'subjectAltName = DNS:localhost,IP:127.0.0.1,IP:::1', 'basicConstraints = critical,CA:FALSE',
60
+ 'keyUsage = critical,digitalSignature', 'extendedKeyUsage = serverAuth', '',
61
+ ].join('\n'),
62
+ { mode: 0o600 },
63
+ )
64
+ // The key is made here (LibreSSL 3.3 writes P-256 keys Bun cannot read) and is 0600 from its first byte; openssl only signs.
65
+ rmSync(tmpK, { force: true })
66
+ writeFileSync(tmpK, generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600, flag: 'wx' })
67
+ const r = Bun.spawnSync([openssl, 'req', '-x509', '-new', '-key', tmpK, '-days', String(DAYS), '-config', conf, '-out', tmpC], {
68
+ stdout: 'ignore', stderr: 'pipe', env: { ...process.env, OPENSSL_CONF: conf },
69
+ })
70
+ if (r.exitCode !== 0 || !existsSync(tmpC) || !existsSync(tmpK)) {
71
+ throw new Error(`openssl could not make the certificate: ${r.stderr.toString().trim().split('\n').pop()}`)
72
+ }
73
+ chmodSync(tmpK, 0o600)
74
+ chmodSync(tmpC, 0o644)
75
+ renameSync(tmpK, keyPath)
76
+ renameSync(tmpC, certPath)
77
+ made = true
78
+ } finally {
79
+ for (const f of [conf, tmpC, tmpK]) rmSync(f, { force: true }) // after a success only the renamed ones are gone already
80
+ }
81
+ }
82
+ chmodSync(keyPath, 0o600)
83
+ return { cert: readFileSync(certPath, 'utf8'), key: readFileSync(keyPath, 'utf8'), certPath, keyPath, made, trust: trustWords(certPath) }
84
+ }
85
+
86
+ function usable(certPath: string, keyPath: string): boolean {
87
+ try {
88
+ if (!statSync(keyPath).isFile()) return false
89
+ const c = new X509Certificate(readFileSync(certPath))
90
+ if (!c.checkPrivateKey(createPrivateKey(readFileSync(keyPath)))) return false // a cert and a key that do not belong together are made anew
91
+ if (c.ca) return false
92
+ if (!/DNS:localhost/.test(c.subjectAltName || '') || !/IP Address:127\.0\.0\.1/.test(c.subjectAltName || '')) return false
93
+ return Date.parse(c.validTo) - Date.now() > RENEW_MS
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ function findOpenssl(): string | null {
100
+ return Bun.which('openssl') ?? (existsSync('/usr/bin/openssl') ? '/usr/bin/openssl' : null)
101
+ }
102
+
103
+ /** A path as one shell word (single-quoted when it has anything but safe characters). */
104
+ export function shq(p: string): string {
105
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, `'\\''`)}'`
106
+ }
107
+
108
+ /** What the person runs to trust the leaf (they run it; a password or Touch ID prompt follows on macOS). */
109
+ export function trustWords(certPath: string): string[] {
110
+ if (process.platform === 'darwin') {
111
+ return [
112
+ 'To let Safari use it, trust this certificate once (macOS asks for your password):',
113
+ ` security add-trusted-cert -r trustRoot -p ssl -k ~/Library/Keychains/login.keychain-db ${shq(certPath)}`,
114
+ 'To undo: security remove-trusted-cert ' + shq(certPath),
115
+ 'It is a leaf for localhost only (not a CA): it cannot vouch for any other site.',
116
+ ]
117
+ }
118
+ return [
119
+ `To use it from a browser, add ${certPath} to the browser's trusted certificates (a leaf for localhost only, not a CA).`,
120
+ ]
121
+ }
package/relay/lock.ts ADDED
@@ -0,0 +1,81 @@
1
+ // W59 S4: the two lock kinds wmd-relay uses (a file next to the keys file, a directory next to the
2
+ // local certificate). Both are taken with an atomic create, hold the owner's pid, and are given up
3
+ // by their owner. A lock a killed process left behind is broken when its owner is gone or it is
4
+ // older than a minute, and breaking is itself done under a lock (`<lock>.break`): two waiters that
5
+ // both saw the same stale lock used to both remove it, and the second removal could take a lock
6
+ // the first waiter had just made.
7
+
8
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ export type LockKind = 'file' | 'dir'
12
+ export const STALE_MS = 60_000
13
+ // A lock with no readable owner pid is one whose owner was killed between making it and writing its pid (a window of microseconds), or whose pid file holds junk. It is judged by a short age, not STALE_MS: waiters give up after 20 s, so 60 s wedged every start.
14
+ export const OWNERLESS_MS = 2_000
15
+
16
+ /** True when this call made the lock. */
17
+ export function tryTake(lock: string, kind: LockKind): boolean {
18
+ try {
19
+ if (kind === 'dir') {
20
+ mkdirSync(lock, { mode: 0o700 })
21
+ writeFileSync(join(lock, 'pid'), String(process.pid))
22
+ } else {
23
+ const fd = openSync(lock, 'wx', 0o600)
24
+ writeSync(fd, String(process.pid))
25
+ closeSync(fd)
26
+ }
27
+ return true
28
+ } catch (e: any) {
29
+ if (e?.code === 'EEXIST') return false
30
+ throw e
31
+ }
32
+ }
33
+
34
+ export function release(lock: string): void {
35
+ rmSync(lock, { recursive: true, force: true })
36
+ }
37
+
38
+ function ownerPid(lock: string, kind: LockKind): number | null {
39
+ try {
40
+ const n = Number(readFileSync(kind === 'dir' ? join(lock, 'pid') : lock, 'utf8').trim())
41
+ return Number.isInteger(n) && n > 0 ? n : null // an empty one is a lock whose owner has not written yet: judged by its age only
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ function alive(pid: number): boolean {
48
+ try {
49
+ process.kill(pid, 0)
50
+ return true
51
+ } catch (e: any) {
52
+ return e?.code !== 'ESRCH' // EPERM: it exists
53
+ }
54
+ }
55
+
56
+ function stale(lock: string, kind: LockKind, ms: number): boolean {
57
+ let age: number
58
+ try {
59
+ age = Date.now() - statSync(lock).mtimeMs
60
+ } catch {
61
+ return false // gone already
62
+ }
63
+ if (age > ms) return true
64
+ const pid = ownerPid(lock, kind)
65
+ if (pid === null) return age > Math.min(ms, OWNERLESS_MS)
66
+ return pid !== process.pid && !alive(pid)
67
+ }
68
+
69
+ /** Remove the lock when its owner is gone (or it is older than `ms`). Only one waiter at a time judges and removes. */
70
+ export function breakIfStale(lock: string, kind: LockKind, ms = STALE_MS): void {
71
+ const brk = `${lock}.break`
72
+ if (!tryTake(brk, 'file')) {
73
+ if (stale(brk, 'file', ms)) rmSync(brk, { force: true }) // the breaker itself was killed
74
+ return
75
+ }
76
+ try {
77
+ if (stale(lock, kind, ms)) rmSync(lock, { recursive: true, force: true })
78
+ } finally {
79
+ rmSync(brk, { force: true })
80
+ }
81
+ }
@@ -0,0 +1,138 @@
1
+ // W59 S7: the hosted relay's log file. Connection metadata only (the engine never hands it payloads or secrets), one JSON
2
+ // object per line, rotated by size and by age, and files past the retention are removed. Rotation renames the live file
3
+ // to relay-<time>.log; removal is by the file's last write time. The default retention (14 days) is provisional.
4
+
5
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ export type LogFileOptions = {
9
+ dir: string
10
+ maxBytes?: number // rotate when the live file would pass this (50 MiB)
11
+ maxAgeMs?: number // rotate when the live file is this old (1 day)
12
+ retainDays?: number // remove rotated files older than this (14)
13
+ maxLogDirBytes?: number // the whole directory stays under this: the oldest rotated files go first (1 GiB)
14
+ warn?: (line: string) => void // a write problem, once per kind (stderr)
15
+ append?: (path: string, text: string) => void // the write itself (tests make it fail)
16
+ now?: () => number
17
+ }
18
+
19
+ const LIVE = 'relay.log'
20
+ const ROTATED = /^relay-\d{8}T\d{9}(-\d+)?\.log$/
21
+
22
+ export function createLogFile(o: LogFileOptions) {
23
+ const now = o.now ?? Date.now
24
+ const maxBytes = o.maxBytes ?? 50 * 2 ** 20
25
+ const maxAgeMs = o.maxAgeMs ?? 24 * 3600_000
26
+ const retainMs = (o.retainDays ?? 14) * 24 * 3600_000
27
+ const maxDir = o.maxLogDirBytes ?? 2 ** 30
28
+ const warn = o.warn ?? ((m: string) => void process.stderr.write(`${m}\n`))
29
+ const append = o.append ?? ((p: string, t: string) => appendFileSync(p, t, { mode: 0o600 }))
30
+ // the directory holds connection metadata: owner only, also when it already existed with a looser mode
31
+ mkdirSync(o.dir, { recursive: true, mode: 0o700 })
32
+ try {
33
+ chmodSync(o.dir, 0o700)
34
+ } catch (e: any) {
35
+ throw new Error(`the log directory ${o.dir} must be owner-only (0700) and cannot be made so: ${e?.message ?? e}`)
36
+ }
37
+ const live = join(o.dir, LIVE)
38
+ let size = existsSync(live) ? statSync(live).size : 0
39
+ let born = existsSync(live) ? statSync(live).birthtimeMs || statSync(live).mtimeMs : now()
40
+ let buf: string[] = []
41
+ let timer: ReturnType<typeof setTimeout> | null = null
42
+
43
+ const stamp = (t: number) => new Date(t).toISOString().replace(/[-:]/g, '').replace('.', '').replace('Z', '') // 20261016T123456789
44
+
45
+ function rotate() {
46
+ if (!existsSync(live) || size === 0) {
47
+ born = now()
48
+ return
49
+ }
50
+ let name = join(o.dir, `relay-${stamp(now())}.log`)
51
+ for (let i = 1; existsSync(name); i++) name = join(o.dir, `relay-${stamp(now())}-${i}.log`)
52
+ renameSync(live, name)
53
+ size = 0
54
+ born = now()
55
+ prune()
56
+ }
57
+
58
+ /** Remove rotated files whose last write is past the retention. Returns the names removed. */
59
+ function prune(): string[] {
60
+ const gone: string[] = []
61
+ for (const f of readdirSync(o.dir)) {
62
+ if (!ROTATED.test(f)) continue
63
+ const p = join(o.dir, f)
64
+ if (now() - statSync(p).mtimeMs > retainMs) {
65
+ rmSync(p, { force: true })
66
+ gone.push(f)
67
+ }
68
+ }
69
+ // the total cap: the oldest rotated files go until the directory fits
70
+ const files = readdirSync(o.dir).filter(f => ROTATED.test(f)).sort()
71
+ let total = existsSync(live) ? statSync(live).size : 0
72
+ const sizes = files.map(f => statSync(join(o.dir, f)).size)
73
+ total += sizes.reduce((a, b) => a + b, 0)
74
+ for (let i = 0; i < files.length && total > maxDir; i++) {
75
+ rmSync(join(o.dir, files[i]), { force: true })
76
+ total -= sizes[i]
77
+ gone.push(files[i])
78
+ }
79
+ return gone
80
+ }
81
+
82
+ const reported = new Set<string>()
83
+ const MAX_PENDING = 8 * 2 ** 20 // while writes fail, hold at most this much text; the oldest lines go first
84
+ function flush() {
85
+ if (timer) clearTimeout(timer)
86
+ timer = null
87
+ if (!buf.length) return
88
+ const text = buf.join('')
89
+ try {
90
+ if (size > 0 && (size + text.length > maxBytes || now() - born > maxAgeMs)) rotate()
91
+ else if (size === 0) born = now()
92
+ try {
93
+ append(live, text)
94
+ } catch (e: any) {
95
+ if (e?.code !== 'ENOENT') throw e
96
+ mkdirSync(o.dir, { recursive: true, mode: 0o700 }) // the directory was removed: make it again and try once more
97
+ chmodSync(o.dir, 0o700)
98
+ size = 0
99
+ append(live, text)
100
+ }
101
+ buf = []
102
+ size += Buffer.byteLength(text)
103
+ reported.clear()
104
+ } catch (e: any) {
105
+ const kind = String(e?.code ?? e?.name ?? 'error')
106
+ if (!reported.has(kind)) {
107
+ reported.add(kind)
108
+ warn(`wmd-relay: cannot write the log ${live} (${kind}: ${e?.message ?? e}); it keeps trying on every flush`)
109
+ }
110
+ let held = buf.reduce((n, l) => n + l.length, 0)
111
+ while (held > MAX_PENDING && buf.length > 1) held -= buf.shift()!.length
112
+ timer = setTimeout(flush, 5000)
113
+ ;(timer as any).unref?.()
114
+ }
115
+ }
116
+
117
+ prune()
118
+ const pruner = setInterval(prune, 3600_000)
119
+ pruner.unref?.()
120
+ return {
121
+ path: live,
122
+ write(e: Record<string, unknown>) {
123
+ buf.push(JSON.stringify({ t: now(), ...e }) + '\n')
124
+ if (buf.length >= 512) flush()
125
+ else if (!timer) {
126
+ timer = setTimeout(flush, 200)
127
+ ;(timer as any).unref?.()
128
+ }
129
+ },
130
+ flush,
131
+ prune,
132
+ rotate: () => (flush(), rotate()),
133
+ close() {
134
+ flush()
135
+ clearInterval(pruner)
136
+ },
137
+ }
138
+ }