@bod.ee/db 0.10.2 → 0.12.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/.claude/settings.local.json +5 -1
- package/.claude/skills/config-file.md +3 -1
- package/.claude/skills/developing-bod-db.md +10 -1
- package/.claude/skills/using-bod-db.md +18 -1
- package/CLAUDE.md +12 -3
- package/admin/admin.ts +9 -0
- package/admin/demo.config.ts +1 -0
- package/admin/ui.html +67 -37
- package/docs/keyauth-integration.md +141 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/client/BodClient.ts +30 -6
- package/src/client/BodClientCached.ts +91 -3
- package/src/server/BodDB.ts +113 -42
- package/src/server/KeyAuthEngine.ts +32 -7
- package/src/server/ReplicationEngine.ts +31 -1
- package/src/server/StorageEngine.ts +117 -6
- package/src/server/StreamEngine.ts +20 -2
- package/src/server/SubscriptionEngine.ts +122 -35
- package/src/server/Transport.ts +125 -15
- package/src/server/VFSEngine.ts +27 -0
- package/src/shared/keyAuth.ts +4 -4
- package/src/shared/logger.ts +68 -0
- package/src/shared/protocol.ts +4 -1
- package/tests/optimization.test.ts +392 -0
package/src/server/Transport.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { AUTH_PREFIX } from './KeyAuthEngine.ts';
|
|
|
6
6
|
import type { ClientMessage } from '../shared/protocol.ts';
|
|
7
7
|
import { Errors } from '../shared/errors.ts';
|
|
8
8
|
import { normalizePath, pathKey } from '../shared/pathUtils.ts';
|
|
9
|
+
import type { ComponentLogger } from '../shared/logger.ts';
|
|
9
10
|
import { increment, serverTimestamp, arrayUnion, arrayRemove } from '../shared/transforms.ts';
|
|
10
11
|
|
|
11
12
|
interface VfsTransfer {
|
|
@@ -31,20 +32,81 @@ export class TransportOptions {
|
|
|
31
32
|
staticRoutes?: Record<string, string>;
|
|
32
33
|
/** Custom REST routes checked before 404. Return null to fall through. */
|
|
33
34
|
extraRoutes?: Record<string, (req: Request, url: URL) => Response | Promise<Response> | null>;
|
|
35
|
+
/** Maximum concurrent WebSocket connections (default 10000) */
|
|
36
|
+
maxConnections: number = 10000;
|
|
37
|
+
/** Maximum incoming WebSocket message size in bytes (default 1MB) */
|
|
38
|
+
maxMessageSize: number = 1_048_576;
|
|
39
|
+
/** Maximum subscriptions per WebSocket client (default 500) */
|
|
40
|
+
maxSubscriptionsPerClient: number = 500;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export class Transport {
|
|
37
44
|
readonly options: TransportOptions;
|
|
38
45
|
private server: Server | null = null;
|
|
39
46
|
private _clients = new Set<ServerWebSocket<WsData>>();
|
|
47
|
+
/** Broadcast groups: path → Set of WS clients */
|
|
48
|
+
private _valueGroups = new Map<string, Set<ServerWebSocket<WsData>>>();
|
|
49
|
+
private _childGroups = new Map<string, Set<ServerWebSocket<WsData>>>();
|
|
50
|
+
/** DB unsub functions for broadcast groups */
|
|
51
|
+
private _groupUnsubs = new Map<string, () => void>();
|
|
52
|
+
private _log: ComponentLogger;
|
|
40
53
|
get clientCount(): number { return this._clients.size; }
|
|
41
54
|
|
|
55
|
+
/** Send pre-serialized stats directly to subscribed WS clients, bypassing SubscriptionEngine. */
|
|
56
|
+
broadcastStats(data: unknown, updatedAt: number): void {
|
|
57
|
+
// Check both exact path and ancestor subscribers
|
|
58
|
+
const group = this._valueGroups.get('_admin/stats');
|
|
59
|
+
const adminGroup = this._valueGroups.get('_admin');
|
|
60
|
+
if (!group?.size && !adminGroup?.size) return;
|
|
61
|
+
const payload = JSON.stringify({ type: 'value', path: '_admin/stats', data, updatedAt });
|
|
62
|
+
if (group) for (const c of group) { try { c.send(payload); } catch {} }
|
|
63
|
+
if (adminGroup) for (const c of adminGroup) { try { c.send(payload); } catch {} }
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
constructor(
|
|
43
67
|
private db: BodDB,
|
|
44
68
|
private rules: RulesEngine | null,
|
|
45
69
|
options?: Partial<TransportOptions>,
|
|
46
70
|
) {
|
|
47
71
|
this.options = { ...new TransportOptions(), ...options };
|
|
72
|
+
this._log = db.log.forComponent('transport');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Add a WS client to a broadcast group. Returns the per-client unsub function. */
|
|
76
|
+
private _addBroadcastSub(ws: ServerWebSocket<WsData>, path: string, event: 'value' | 'child'): () => void {
|
|
77
|
+
const groups = event === 'value' ? this._valueGroups : this._childGroups;
|
|
78
|
+
const unsubKey = `${event}:${path}`;
|
|
79
|
+
|
|
80
|
+
if (!groups.has(path)) {
|
|
81
|
+
groups.set(path, new Set());
|
|
82
|
+
const dbOff = event === 'value'
|
|
83
|
+
? this.db.on(path, (snap) => {
|
|
84
|
+
const group = groups.get(path);
|
|
85
|
+
if (!group?.size) return;
|
|
86
|
+
const data = snap.val();
|
|
87
|
+
const updatedAt = snap.updatedAt ?? this.db.storage.getWithMeta(snap.path)?.updatedAt;
|
|
88
|
+
const payload = JSON.stringify({ type: 'value', path: snap.path, data, updatedAt });
|
|
89
|
+
for (const c of group) { try { c.send(payload); } catch {} }
|
|
90
|
+
})
|
|
91
|
+
: this.db.onChild(path, (ev) => {
|
|
92
|
+
const group = groups.get(path);
|
|
93
|
+
if (!group?.size) return;
|
|
94
|
+
const payload = JSON.stringify({ type: 'child', path, key: ev.key, data: ev.val(), event: ev.type });
|
|
95
|
+
for (const c of group) { try { c.send(payload); } catch {} }
|
|
96
|
+
});
|
|
97
|
+
this._groupUnsubs.set(unsubKey, dbOff);
|
|
98
|
+
}
|
|
99
|
+
groups.get(path)!.add(ws);
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
const group = groups.get(path);
|
|
103
|
+
group?.delete(ws);
|
|
104
|
+
if (group?.size === 0) {
|
|
105
|
+
groups.delete(path);
|
|
106
|
+
this._groupUnsubs.get(unsubKey)?.();
|
|
107
|
+
this._groupUnsubs.delete(unsubKey);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
48
110
|
}
|
|
49
111
|
|
|
50
112
|
private async extractAuth(req: Request): Promise<Record<string, unknown> | null> {
|
|
@@ -75,6 +137,8 @@ export class Transport {
|
|
|
75
137
|
}
|
|
76
138
|
}
|
|
77
139
|
|
|
140
|
+
if (this._log.isDebug) this._log.debug(`${req.method} ${url.pathname}`);
|
|
141
|
+
|
|
78
142
|
// REST: GET /db/<path> → get
|
|
79
143
|
if (req.method === 'GET' && url.pathname.startsWith('/db/')) {
|
|
80
144
|
return (async () => {
|
|
@@ -255,7 +319,7 @@ export class Transport {
|
|
|
255
319
|
return Response.json({ ok: true });
|
|
256
320
|
}
|
|
257
321
|
|
|
258
|
-
return
|
|
322
|
+
return Response.json({ ok: false, error: 'Method not allowed', code: Errors.INTERNAL }, { status: 405 });
|
|
259
323
|
})();
|
|
260
324
|
}
|
|
261
325
|
|
|
@@ -275,16 +339,25 @@ export class Transport {
|
|
|
275
339
|
}
|
|
276
340
|
}
|
|
277
341
|
|
|
278
|
-
return
|
|
342
|
+
return Response.json({ ok: false, error: 'Not found', code: Errors.NOT_FOUND }, { status: 404 });
|
|
279
343
|
}
|
|
280
344
|
|
|
281
345
|
/** WebSocket handler config for Bun.serve() */
|
|
282
346
|
get websocketConfig() {
|
|
283
347
|
const self = this;
|
|
284
348
|
return {
|
|
285
|
-
open(ws: ServerWebSocket<WsData>) {
|
|
349
|
+
open(ws: ServerWebSocket<WsData>) {
|
|
350
|
+
if (self._clients.size >= self.options.maxConnections) {
|
|
351
|
+
ws.close(1008, 'At capacity');
|
|
352
|
+
self._log.warn(`Connection rejected: at capacity (${self.options.maxConnections})`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
self._clients.add(ws);
|
|
356
|
+
self._log.debug(`Client connected (${self._clients.size} total)`);
|
|
357
|
+
},
|
|
286
358
|
close(ws: ServerWebSocket<WsData>) {
|
|
287
359
|
self._clients.delete(ws);
|
|
360
|
+
self._log.debug(`Client disconnected (${self._clients.size} total)`);
|
|
288
361
|
for (const off of ws.data.valueSubs.values()) off();
|
|
289
362
|
for (const off of ws.data.childSubs.values()) off();
|
|
290
363
|
for (const off of ws.data.streamSubs.values()) off();
|
|
@@ -293,6 +366,11 @@ export class Transport {
|
|
|
293
366
|
ws.data.streamSubs.clear();
|
|
294
367
|
},
|
|
295
368
|
async message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
369
|
+
const rawLen = typeof raw === 'string' ? Buffer.byteLength(raw) : raw.byteLength;
|
|
370
|
+
if (rawLen > self.options.maxMessageSize) {
|
|
371
|
+
ws.send(JSON.stringify({ id: '?', ok: false, error: 'Message too large', code: Errors.INTERNAL }));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
296
374
|
let msg: ClientMessage;
|
|
297
375
|
try {
|
|
298
376
|
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
@@ -303,7 +381,10 @@ export class Transport {
|
|
|
303
381
|
|
|
304
382
|
const { id } = msg;
|
|
305
383
|
const reply = (data: unknown, extra?: Record<string, unknown>) => ws.send(JSON.stringify({ id, ok: true, data, ...extra }));
|
|
306
|
-
const error = (err: string, code: string) =>
|
|
384
|
+
const error = (err: string, code: string) => {
|
|
385
|
+
self._log.warn(`WS op=${msg.op} path=${(msg as any).path ?? '—'} → ${err}`);
|
|
386
|
+
ws.send(JSON.stringify({ id, ok: false, error: err, code }));
|
|
387
|
+
};
|
|
307
388
|
// Block external writes to _auth/ prefix (only KeyAuthEngine may write)
|
|
308
389
|
const guardAuthPrefix = (path: string) => {
|
|
309
390
|
if (self.options.keyAuth && normalizePath(path).startsWith(AUTH_PREFIX)) {
|
|
@@ -394,21 +475,16 @@ export class Transport {
|
|
|
394
475
|
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
395
476
|
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
396
477
|
}
|
|
478
|
+
if (ws.data.valueSubs.size + ws.data.childSubs.size >= self.options.maxSubscriptionsPerClient) {
|
|
479
|
+
return error('Per-client subscription limit reached', Errors.INTERNAL);
|
|
480
|
+
}
|
|
397
481
|
const subKey = `${msg.event}:${msg.path}`;
|
|
398
482
|
if (msg.event === 'value') {
|
|
399
483
|
if (ws.data.valueSubs.has(subKey)) return reply(null);
|
|
400
|
-
|
|
401
|
-
const meta = self.db.storage.getWithMeta(snap.path);
|
|
402
|
-
ws.send(JSON.stringify({ type: 'value', path: snap.path, data: snap.val(), updatedAt: meta?.updatedAt }));
|
|
403
|
-
});
|
|
404
|
-
ws.data.valueSubs.set(subKey, off);
|
|
484
|
+
ws.data.valueSubs.set(subKey, self._addBroadcastSub(ws, msg.path, 'value'));
|
|
405
485
|
} else if (msg.event === 'child') {
|
|
406
486
|
if (ws.data.childSubs.has(subKey)) return reply(null);
|
|
407
|
-
|
|
408
|
-
const off = self.db.onChild(subPath, (event) => {
|
|
409
|
-
ws.send(JSON.stringify({ type: 'child', path: subPath, key: event.key, data: event.val(), event: event.type }));
|
|
410
|
-
});
|
|
411
|
-
ws.data.childSubs.set(subKey, off);
|
|
487
|
+
ws.data.childSubs.set(subKey, self._addBroadcastSub(ws, msg.path, 'child'));
|
|
412
488
|
}
|
|
413
489
|
return reply(null);
|
|
414
490
|
}
|
|
@@ -677,6 +753,14 @@ export class Transport {
|
|
|
677
753
|
}
|
|
678
754
|
return reply(self.db.vfs.list(msg.path));
|
|
679
755
|
}
|
|
756
|
+
case 'vfs-tree': {
|
|
757
|
+
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
758
|
+
if (self.rules && !self.rules.check('read', `_vfs/${msg.path}`, ws.data.auth)) {
|
|
759
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
760
|
+
}
|
|
761
|
+
const hiddenPaths = msg.hiddenPaths ? new Set(msg.hiddenPaths) : undefined;
|
|
762
|
+
return reply(self.db.vfs.tree(msg.path, { hiddenPaths, hideDotfiles: msg.hideDotfiles }));
|
|
763
|
+
}
|
|
680
764
|
case 'vfs-delete': {
|
|
681
765
|
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
682
766
|
if (self.rules && !self.rules.check('write', `_vfs/${msg.path}`, ws.data.auth)) {
|
|
@@ -708,10 +792,14 @@ export class Transport {
|
|
|
708
792
|
case 'auth-verify': {
|
|
709
793
|
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
710
794
|
const result = self.options.keyAuth.verify(msg.publicKey, msg.signature, msg.nonce);
|
|
711
|
-
if (!result)
|
|
795
|
+
if (!result) {
|
|
796
|
+
self._log.warn(`Auth failed for key ${msg.publicKey?.slice(0, 16)}…`);
|
|
797
|
+
return error('Authentication failed', Errors.AUTH_REQUIRED);
|
|
798
|
+
}
|
|
712
799
|
// Also set ws auth context
|
|
713
800
|
const ctx = self.options.keyAuth.validateToken(result.token);
|
|
714
801
|
if (ctx) ws.data.auth = ctx as unknown as Record<string, unknown>;
|
|
802
|
+
self._log.info(`Auth succeeded: ${(ctx as any)?.fingerprint?.slice(0, 12) ?? 'unknown'}…`);
|
|
715
803
|
return reply(result);
|
|
716
804
|
}
|
|
717
805
|
case 'auth-link-device': {
|
|
@@ -856,6 +944,20 @@ export class Transport {
|
|
|
856
944
|
return reply(info);
|
|
857
945
|
}
|
|
858
946
|
|
|
947
|
+
case 'batch-sub': {
|
|
948
|
+
let count = 0;
|
|
949
|
+
for (const sub of msg.subscriptions) {
|
|
950
|
+
if (self.rules && !self.rules.check('read', sub.path, ws.data.auth)) continue;
|
|
951
|
+
if (ws.data.valueSubs.size + ws.data.childSubs.size >= self.options.maxSubscriptionsPerClient) break;
|
|
952
|
+
const bSubKey = `${sub.event}:${sub.path}`;
|
|
953
|
+
const subsMap = sub.event === 'value' ? ws.data.valueSubs : ws.data.childSubs;
|
|
954
|
+
if (subsMap.has(bSubKey)) { count++; continue; }
|
|
955
|
+
subsMap.set(bSubKey, self._addBroadcastSub(ws, sub.path, sub.event as 'value' | 'child'));
|
|
956
|
+
count++;
|
|
957
|
+
}
|
|
958
|
+
return reply({ subscribed: count });
|
|
959
|
+
}
|
|
960
|
+
|
|
859
961
|
case 'transform': {
|
|
860
962
|
const { path: tPath, type, value: tVal } = msg as any;
|
|
861
963
|
let sentinel: unknown;
|
|
@@ -887,11 +989,19 @@ export class Transport {
|
|
|
887
989
|
return reply(summary);
|
|
888
990
|
}
|
|
889
991
|
|
|
992
|
+
case 'admin-stats-toggle': {
|
|
993
|
+
const enable = msg.enabled;
|
|
994
|
+
if (enable) self.db.startStatsPublisher();
|
|
995
|
+
else self.db.stopStatsPublisher();
|
|
996
|
+
return reply({ enabled: self.db.statsEnabled });
|
|
997
|
+
}
|
|
998
|
+
|
|
890
999
|
default:
|
|
891
1000
|
return error('Unknown operation', Errors.INTERNAL);
|
|
892
1001
|
}
|
|
893
1002
|
} catch (e: unknown) {
|
|
894
1003
|
const message = e instanceof Error ? e.message : 'Internal error';
|
|
1004
|
+
self._log.error(`op=${msg.op} path=${msg.path ?? '—'} error: ${message}`);
|
|
895
1005
|
return error(message, Errors.INTERNAL);
|
|
896
1006
|
}
|
|
897
1007
|
},
|
package/src/server/VFSEngine.ts
CHANGED
|
@@ -183,6 +183,33 @@ export class VFSEngine {
|
|
|
183
183
|
return results;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
tree(virtualPath: string, opts?: { hiddenPaths?: Set<string>; hideDotfiles?: boolean }): any[] {
|
|
187
|
+
const hiddenPaths = opts?.hiddenPaths ?? new Set<string>();
|
|
188
|
+
const hideDotfiles = opts?.hideDotfiles ?? false;
|
|
189
|
+
|
|
190
|
+
const walk = (vPath: string): any[] => {
|
|
191
|
+
const stats = this.list(vPath);
|
|
192
|
+
stats.sort((a, b) => {
|
|
193
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
194
|
+
return a.name.localeCompare(b.name);
|
|
195
|
+
});
|
|
196
|
+
const result: any[] = [];
|
|
197
|
+
for (const s of stats) {
|
|
198
|
+
if (hiddenPaths.has(s.name)) continue;
|
|
199
|
+
if (hideDotfiles && s.name.startsWith('.')) continue;
|
|
200
|
+
if (s.isDir) {
|
|
201
|
+
const childPath = vPath ? `${vPath}/${s.name}` : s.name;
|
|
202
|
+
result.push({ name: s.name, type: 'directory', children: walk(childPath) });
|
|
203
|
+
} else {
|
|
204
|
+
result.push({ name: s.name, type: 'file' });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return walk(normalizePath(virtualPath));
|
|
211
|
+
}
|
|
212
|
+
|
|
186
213
|
mkdir(virtualPath: string): FileStat {
|
|
187
214
|
const vp = normalizePath(virtualPath);
|
|
188
215
|
const name = vp.split('/').pop()!;
|
package/src/shared/keyAuth.ts
CHANGED
|
@@ -119,10 +119,10 @@ const PBKDF2_ITERATIONS = 100_000;
|
|
|
119
119
|
const PBKDF2_KEYLEN = 32; // 256 bits
|
|
120
120
|
const PBKDF2_DIGEST = 'sha256';
|
|
121
121
|
|
|
122
|
-
export function encryptPrivateKey(privateKeyDer: Uint8Array, password: string): { encrypted: string; salt: string; iv: string; authTag: string } {
|
|
122
|
+
export function encryptPrivateKey(privateKeyDer: Uint8Array, password: string, iterations?: number): { encrypted: string; salt: string; iv: string; authTag: string } {
|
|
123
123
|
const salt = randomBytes(16);
|
|
124
124
|
const iv = randomBytes(12); // 96-bit for GCM
|
|
125
|
-
const derivedKey = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
125
|
+
const derivedKey = pbkdf2Sync(password, salt, iterations ?? PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
126
126
|
const cipher = createCipheriv('aes-256-gcm', derivedKey, iv);
|
|
127
127
|
const encrypted = Buffer.concat([cipher.update(privateKeyDer), cipher.final()]);
|
|
128
128
|
const authTag = cipher.getAuthTag();
|
|
@@ -134,8 +134,8 @@ export function encryptPrivateKey(privateKeyDer: Uint8Array, password: string):
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
export function decryptPrivateKey(encrypted: string, salt: string, iv: string, authTag: string, password: string): Uint8Array {
|
|
138
|
-
const derivedKey = pbkdf2Sync(password, Buffer.from(salt, 'base64'), PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
137
|
+
export function decryptPrivateKey(encrypted: string, salt: string, iv: string, authTag: string, password: string, iterations?: number): Uint8Array {
|
|
138
|
+
const derivedKey = pbkdf2Sync(password, Buffer.from(salt, 'base64'), iterations ?? PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
139
139
|
const decipher = createDecipheriv('aes-256-gcm', derivedKey, Buffer.from(iv, 'base64'));
|
|
140
140
|
decipher.setAuthTag(Buffer.from(authTag, 'base64'));
|
|
141
141
|
const decrypted = Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64')), decipher.final()]);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BodDB internal logger — config-driven, zero-dependency.
|
|
3
|
+
* Disabled by default. Enable via `log` option in BodDBOptions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
7
|
+
|
|
8
|
+
export interface LogConfig {
|
|
9
|
+
/** Enable logging (default: false) */
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
/** Minimum log level (default: 'info') */
|
|
12
|
+
level?: LogLevel;
|
|
13
|
+
/** Enable specific components: ['storage', 'transport', 'subs', 'replication', 'stats', 'keyauth'] or '*' for all */
|
|
14
|
+
components?: string[] | '*';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
18
|
+
|
|
19
|
+
export class Logger {
|
|
20
|
+
private enabled: boolean;
|
|
21
|
+
private minLevel: number;
|
|
22
|
+
private components: Set<string> | '*';
|
|
23
|
+
private _cache = new Map<string, ComponentLogger>();
|
|
24
|
+
|
|
25
|
+
constructor(config?: LogConfig) {
|
|
26
|
+
this.enabled = config?.enabled ?? false;
|
|
27
|
+
this.minLevel = LEVELS[config?.level ?? 'info'];
|
|
28
|
+
this.components = config?.components === '*' ? '*' : new Set(config?.components ?? []);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
forComponent(name: string): ComponentLogger {
|
|
32
|
+
let cl = this._cache.get(name);
|
|
33
|
+
if (!cl) { cl = new ComponentLogger(this, name); this._cache.set(name, cl); }
|
|
34
|
+
return cl;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
shouldLog(level: LogLevel, component: string): boolean {
|
|
38
|
+
if (!this.enabled) return false;
|
|
39
|
+
if (LEVELS[level] < this.minLevel) return false;
|
|
40
|
+
if (this.components !== '*' && !this.components.has(component)) return false;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
log(level: LogLevel, component: string, msg: string, data?: unknown): void {
|
|
45
|
+
if (!this.shouldLog(level, component)) return;
|
|
46
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
47
|
+
const prefix = `[${ts}] [${level.toUpperCase()}] [${component}]`;
|
|
48
|
+
if (data !== undefined) {
|
|
49
|
+
console[level === 'debug' ? 'log' : level](prefix, msg, data);
|
|
50
|
+
} else {
|
|
51
|
+
console[level === 'debug' ? 'log' : level](prefix, msg);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class ComponentLogger {
|
|
57
|
+
readonly isDebug: boolean;
|
|
58
|
+
constructor(private logger: Logger, private component: string) {
|
|
59
|
+
this.isDebug = logger.shouldLog('debug', component);
|
|
60
|
+
}
|
|
61
|
+
debug(msg: string, data?: unknown) { this.logger.log('debug', this.component, msg, data); }
|
|
62
|
+
info(msg: string, data?: unknown) { this.logger.log('info', this.component, msg, data); }
|
|
63
|
+
warn(msg: string, data?: unknown) { this.logger.log('warn', this.component, msg, data); }
|
|
64
|
+
error(msg: string, data?: unknown) { this.logger.log('error', this.component, msg, data); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Global no-op logger (used when logging disabled) */
|
|
68
|
+
export const noopLogger = new Logger();
|
package/src/shared/protocol.ts
CHANGED
|
@@ -36,6 +36,7 @@ export type ClientMessage =
|
|
|
36
36
|
| { id: string; op: 'vfs-download-init'; path: string }
|
|
37
37
|
| { id: string; op: 'vfs-stat'; path: string }
|
|
38
38
|
| { id: string; op: 'vfs-list'; path: string }
|
|
39
|
+
| { id: string; op: 'vfs-tree'; path: string; hiddenPaths?: string[]; hideDotfiles?: boolean }
|
|
39
40
|
| { id: string; op: 'vfs-delete'; path: string }
|
|
40
41
|
| { id: string; op: 'vfs-mkdir'; path: string }
|
|
41
42
|
| { id: string; op: 'vfs-move'; path: string; dst: string }
|
|
@@ -65,7 +66,9 @@ export type ClientMessage =
|
|
|
65
66
|
| { id: string; op: 'transform'; path: string; type: string; value?: unknown }
|
|
66
67
|
| { id: string; op: 'set-ttl'; path: string; value: unknown; ttl: number }
|
|
67
68
|
| { id: string; op: 'sweep' }
|
|
68
|
-
| { id: string; op: 'get-rules' }
|
|
69
|
+
| { id: string; op: 'get-rules' }
|
|
70
|
+
| { id: string; op: 'batch-sub'; subscriptions: Array<{ path: string; event: SubEvent }> }
|
|
71
|
+
| { id: string; op: 'admin-stats-toggle'; enabled: boolean };
|
|
69
72
|
|
|
70
73
|
// Server → Client messages
|
|
71
74
|
export type ServerMessage =
|