@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.
@@ -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 new Response('Method not allowed', { status: 405 });
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 new Response('Not Found', { status: 404 });
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>) { self._clients.add(ws); },
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) => ws.send(JSON.stringify({ id, ok: false, error: err, code }));
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
- const off = self.db.on(msg.path, (snap) => {
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
- const subPath = msg.path;
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) return error('Authentication failed', Errors.AUTH_REQUIRED);
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
  },
@@ -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()!;
@@ -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();
@@ -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 =