@threadedstack/wmd-web 0.0.0-stage → 0.1.12
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/README.md +2601 -2
- package/RELEASE.json +123 -0
- package/export-page/coi-sw.js +20 -0
- package/export-page/index.html +17 -0
- package/export-page/run.css +10 -0
- package/export-page/run.js +236 -0
- package/guest/arm64/Image +4 -0
- package/guest/arm64/Image-4k +4 -0
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +36 -4
- package/page/sw.js +381 -0
- package/relay/control.ts +139 -0
- package/relay/engine.ts +787 -0
- package/relay/hosted.ts +660 -0
- package/relay/keys.ts +53 -0
- package/relay/local-tls.ts +121 -0
- package/relay/lock.ts +81 -0
- package/relay/logfile.ts +138 -0
- package/relay/main.ts +642 -0
- package/relay/policy.ts +241 -0
- package/relay/relay.ts +266 -0
- package/relay/token.ts +116 -0
- package/relay/v1.ts +107 -0
- package/relay/wisp.ts +615 -0
- package/src/base.js +325 -0
- package/src/boot.js +2037 -0
- package/src/calib-worker.js +40 -0
- package/src/fs/9p/codec.js +303 -0
- package/src/fs/9p/server.js +521 -0
- package/src/fs/errno.js +43 -0
- package/src/fs/graph.js +380 -0
- package/src/fs/overlay.js +1018 -0
- package/src/fs/pack-write.js +165 -0
- package/src/fs/pack.js +427 -0
- package/src/fs/seed.js +22 -0
- package/src/fs/svc-snapshot.js +65 -0
- package/src/fs/upper-pack.js +112 -0
- package/src/host/base-cache.js +195 -0
- package/src/host/base-file.js +328 -0
- package/src/host/base-io.js +93 -0
- package/src/host/boot-events.js +209 -0
- package/src/host/chan-service.js +300 -0
- package/src/host/chan-services.js +689 -0
- package/src/host/chan.js +655 -0
- package/src/host/ckpt-file.js +395 -0
- package/src/host/ckpt-io.js +207 -0
- package/src/host/ckpt-point.js +160 -0
- package/src/host/ckpt-save.js +64 -0
- package/src/host/ckpt.js +434 -0
- package/src/host/clock.js +111 -0
- package/src/host/ctl.js +778 -0
- package/src/host/errno.js +18 -0
- package/src/host/fs.js +167 -0
- package/src/host/gcnudge.js +83 -0
- package/src/host/i64shim.js +81 -0
- package/src/host/jit-cache.js +341 -0
- package/src/host/jit-hub-core.js +208 -0
- package/src/host/jit-hub.js +93 -0
- package/src/host/jit-snap.js +226 -0
- package/src/host/jit.js +1903 -0
- package/src/host/layout.js +100 -0
- package/src/host/monitor.js +110 -0
- package/src/host/p9mount.js +27 -0
- package/src/host/ram-mem.js +125 -0
- package/src/host/stdio.js +143 -0
- package/src/host/threads.js +109 -0
- package/src/host/wasi.js +335 -0
- package/src/host/wmd-imports.js +71 -0
- package/src/host/yamux.js +387 -0
- package/src/image-files.js +154 -0
- package/src/net/dhcp-dns.js +126 -0
- package/src/net/net-worker.js +172 -0
- package/src/net/packet.js +227 -0
- package/src/net/peer.js +230 -0
- package/src/net/proto.js +189 -0
- package/src/net/relay-client.js +226 -0
- package/src/net/stack.js +867 -0
- package/src/net/switch.js +160 -0
- package/src/net/wisp-client.js +570 -0
- package/src/net/wisp-transport.js +196 -0
- package/src/net/wisp.js +511 -0
- package/src/registry/client.js +176 -0
- package/src/registry/converter.js +68 -0
- package/src/registry/hub.js +221 -0
- package/src/registry/oci.js +76 -0
- package/src/registry/pack-worker.js +70 -0
- package/src/registry/pull.js +258 -0
- package/src/registry/ref.js +110 -0
- package/src/registry/wasi-mem.js +355 -0
- package/src/worker.js +315 -0
- 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
|
+
}
|
package/relay/logfile.ts
ADDED
|
@@ -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
|
+
}
|