@bod.ee/db 0.10.2 → 0.11.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 +11 -3
- package/admin/admin.ts +9 -0
- package/admin/demo.config.ts +1 -0
- package/admin/ui.html +67 -37
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/client/BodClient.ts +26 -6
- 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 +117 -15
- package/src/shared/keyAuth.ts +4 -4
- package/src/shared/logger.ts +68 -0
- package/src/shared/protocol.ts +3 -1
- package/tests/optimization.test.ts +392 -0
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import { ancestors } from '../shared/pathUtils.ts';
|
|
2
2
|
|
|
3
|
-
export type SubscriptionCallback = (snapshot: { path: string; val: () => unknown }) => void;
|
|
3
|
+
export type SubscriptionCallback = (snapshot: { path: string; val: () => unknown; updatedAt?: number }) => void;
|
|
4
4
|
export type ChildCallback = (event: { type: 'added' | 'changed' | 'removed'; key: string; path: string; val: () => unknown }) => void;
|
|
5
5
|
|
|
6
|
+
export class SubscriptionEngineOptions {
|
|
7
|
+
/** Maximum total subscriptions (default 50000) */
|
|
8
|
+
maxSubscriptions: number = 50000;
|
|
9
|
+
/** Enable async batched notifications (default false) */
|
|
10
|
+
asyncNotify: boolean = false;
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export class SubscriptionEngine {
|
|
14
|
+
readonly options: SubscriptionEngineOptions;
|
|
7
15
|
private valueSubs = new Map<string, Set<SubscriptionCallback>>();
|
|
8
16
|
private childSubs = new Map<string, Set<ChildCallback>>();
|
|
17
|
+
private _pendingNotify: Set<string> | null = null;
|
|
18
|
+
private _pendingGetFn: ((path: string) => unknown) | null = null;
|
|
19
|
+
private _pendingExisted: Set<string> | null = null;
|
|
20
|
+
private _flushScheduled = false;
|
|
21
|
+
|
|
22
|
+
constructor(options?: Partial<SubscriptionEngineOptions>) {
|
|
23
|
+
this.options = { ...new SubscriptionEngineOptions(), ...options };
|
|
24
|
+
}
|
|
9
25
|
|
|
10
26
|
get hasSubscriptions(): boolean {
|
|
11
27
|
return this.valueSubs.size > 0 || this.childSubs.size > 0;
|
|
@@ -31,6 +47,7 @@ export class SubscriptionEngine {
|
|
|
31
47
|
|
|
32
48
|
/** Subscribe to value changes at a path (fires for exact + descendant writes) */
|
|
33
49
|
onValue(path: string, cb: SubscriptionCallback): () => void {
|
|
50
|
+
if (this.size >= this.options.maxSubscriptions) throw new Error('Subscription limit reached');
|
|
34
51
|
if (!this.valueSubs.has(path)) this.valueSubs.set(path, new Set());
|
|
35
52
|
this.valueSubs.get(path)!.add(cb);
|
|
36
53
|
return () => {
|
|
@@ -41,6 +58,7 @@ export class SubscriptionEngine {
|
|
|
41
58
|
|
|
42
59
|
/** Subscribe to child events at a path */
|
|
43
60
|
onChild(path: string, cb: ChildCallback): () => void {
|
|
61
|
+
if (this.size >= this.options.maxSubscriptions) throw new Error('Subscription limit reached');
|
|
44
62
|
if (!this.childSubs.has(path)) this.childSubs.set(path, new Set());
|
|
45
63
|
this.childSubs.get(path)!.add(cb);
|
|
46
64
|
return () => {
|
|
@@ -49,6 +67,28 @@ export class SubscriptionEngine {
|
|
|
49
67
|
};
|
|
50
68
|
}
|
|
51
69
|
|
|
70
|
+
/** Push a value directly to subscribers of a specific path + its ancestors. No DB involved. */
|
|
71
|
+
pushValue(path: string, value: unknown): void {
|
|
72
|
+
const valFn = () => value;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const subs = this.valueSubs.get(path);
|
|
75
|
+
if (subs?.size) {
|
|
76
|
+
const snapshot = { path, val: valFn, updatedAt: now };
|
|
77
|
+
for (const cb of subs) { try { cb(snapshot); } catch {} }
|
|
78
|
+
}
|
|
79
|
+
// Walk ancestors by finding '/' separators (avoids split/slice/join allocations)
|
|
80
|
+
let idx = path.lastIndexOf('/');
|
|
81
|
+
while (idx > 0) {
|
|
82
|
+
const ancestor = path.substring(0, idx);
|
|
83
|
+
const asubs = this.valueSubs.get(ancestor);
|
|
84
|
+
if (asubs?.size) {
|
|
85
|
+
const snap = { path: ancestor, val: valFn, updatedAt: now };
|
|
86
|
+
for (const cb of asubs) { try { cb(snap); } catch {} }
|
|
87
|
+
}
|
|
88
|
+
idx = path.lastIndexOf('/', idx - 1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
/**
|
|
53
93
|
* Notify after a write.
|
|
54
94
|
* @param changedPaths - all leaf paths that were written
|
|
@@ -56,56 +96,103 @@ export class SubscriptionEngine {
|
|
|
56
96
|
* @param existedBefore - set of paths that existed before the write (for added/changed detection)
|
|
57
97
|
*/
|
|
58
98
|
notify(changedPaths: string[], getFn: (path: string) => unknown, existedBefore?: Set<string>) {
|
|
59
|
-
|
|
99
|
+
// Value subscriptions — walk each leaf + ancestors using indexOf (no array alloc)
|
|
100
|
+
const notified = changedPaths.length > 1 ? new Set<string>() : null;
|
|
60
101
|
|
|
61
102
|
for (const leafPath of changedPaths) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
103
|
+
// Notify exact path
|
|
104
|
+
if (!notified || !notified.has(leafPath)) {
|
|
105
|
+
notified?.add(leafPath);
|
|
106
|
+
const subs = this.valueSubs.get(leafPath);
|
|
107
|
+
if (subs?.size) {
|
|
108
|
+
const snapshot = { path: leafPath, val: () => getFn(leafPath) };
|
|
109
|
+
for (const cb of subs) { try { cb(snapshot); } catch {} }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Walk ancestors via lastIndexOf (no split/slice/join)
|
|
113
|
+
let idx = leafPath.lastIndexOf('/');
|
|
114
|
+
while (idx > 0) {
|
|
115
|
+
const ancestor = leafPath.substring(0, idx);
|
|
116
|
+
if (notified && notified.has(ancestor)) { idx = leafPath.lastIndexOf('/', idx - 1); continue; }
|
|
117
|
+
notified?.add(ancestor);
|
|
118
|
+
const subs = this.valueSubs.get(ancestor);
|
|
69
119
|
if (subs?.size) {
|
|
70
|
-
const snapshot = { path:
|
|
71
|
-
for (const cb of subs) cb(snapshot);
|
|
120
|
+
const snapshot = { path: ancestor, val: () => getFn(ancestor) };
|
|
121
|
+
for (const cb of subs) { try { cb(snapshot); } catch {} }
|
|
72
122
|
}
|
|
123
|
+
idx = leafPath.lastIndexOf('/', idx - 1);
|
|
73
124
|
}
|
|
74
125
|
}
|
|
75
126
|
|
|
76
|
-
// Child events
|
|
77
|
-
|
|
78
|
-
for (const leafPath of changedPaths) {
|
|
79
|
-
const parts = leafPath.split('/');
|
|
80
|
-
if (parts.length < 2) continue;
|
|
127
|
+
// Child events — skip entirely when no child subs
|
|
128
|
+
if (!this.childSubs.size) return;
|
|
81
129
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
130
|
+
const childNotified = changedPaths.length > 1 ? new Set<string>() : null;
|
|
131
|
+
for (const leafPath of changedPaths) {
|
|
132
|
+
// Walk parent/child pairs via indexOf
|
|
133
|
+
let start = 0;
|
|
134
|
+
let slashIdx = leafPath.indexOf('/', start);
|
|
135
|
+
while (slashIdx !== -1) {
|
|
136
|
+
const parentPath = leafPath.substring(0, slashIdx);
|
|
137
|
+
const nextSlash = leafPath.indexOf('/', slashIdx + 1);
|
|
138
|
+
const childKey = nextSlash === -1 ? leafPath.substring(slashIdx + 1) : leafPath.substring(slashIdx + 1, nextSlash);
|
|
85
139
|
const dedupKey = `${parentPath}/${childKey}`;
|
|
86
140
|
|
|
87
|
-
if (childNotified.has(dedupKey))
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
141
|
+
if (!childNotified || !childNotified.has(dedupKey)) {
|
|
142
|
+
childNotified?.add(dedupKey);
|
|
143
|
+
const subs = this.childSubs.get(parentPath);
|
|
144
|
+
if (subs?.size) {
|
|
145
|
+
const childPath = dedupKey;
|
|
146
|
+
const val = getFn(childPath);
|
|
147
|
+
let type: 'added' | 'changed' | 'removed';
|
|
148
|
+
if (val === null) {
|
|
149
|
+
type = 'removed';
|
|
150
|
+
} else if (existedBefore && !existedBefore.has(childPath)) {
|
|
151
|
+
type = 'added';
|
|
152
|
+
} else {
|
|
153
|
+
type = 'changed';
|
|
154
|
+
}
|
|
155
|
+
const event = { type, key: childKey, path: childPath, val: () => val };
|
|
156
|
+
for (const cb of subs) { try { cb(event); } catch {} }
|
|
101
157
|
}
|
|
102
|
-
const event = { type, key: childKey, path: childPath, val: () => val };
|
|
103
|
-
for (const cb of subs) cb(event);
|
|
104
158
|
}
|
|
159
|
+
start = slashIdx + 1;
|
|
160
|
+
slashIdx = leafPath.indexOf('/', start);
|
|
105
161
|
}
|
|
106
162
|
}
|
|
107
163
|
}
|
|
108
164
|
|
|
165
|
+
/** Queue notifications for async batched delivery. Coalesces multiple writes in same tick. */
|
|
166
|
+
queueNotify(changedPaths: string[], getFn: (path: string) => unknown, existedBefore?: Set<string>) {
|
|
167
|
+
if (!this.options.asyncNotify) {
|
|
168
|
+
return this.notify(changedPaths, getFn, existedBefore);
|
|
169
|
+
}
|
|
170
|
+
if (!this._pendingNotify) this._pendingNotify = new Set();
|
|
171
|
+
for (const p of changedPaths) this._pendingNotify.add(p);
|
|
172
|
+
this._pendingGetFn = getFn;
|
|
173
|
+
if (existedBefore) {
|
|
174
|
+
if (!this._pendingExisted) this._pendingExisted = new Set();
|
|
175
|
+
for (const p of existedBefore) this._pendingExisted.add(p);
|
|
176
|
+
}
|
|
177
|
+
if (!this._flushScheduled) {
|
|
178
|
+
this._flushScheduled = true;
|
|
179
|
+
queueMicrotask(() => this._flushNotify());
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private _flushNotify() {
|
|
184
|
+
this._flushScheduled = false;
|
|
185
|
+
const paths = this._pendingNotify;
|
|
186
|
+
const getFn = this._pendingGetFn;
|
|
187
|
+
const existed = this._pendingExisted;
|
|
188
|
+
this._pendingNotify = null;
|
|
189
|
+
this._pendingGetFn = null;
|
|
190
|
+
this._pendingExisted = null;
|
|
191
|
+
if (paths && getFn) {
|
|
192
|
+
this.notify([...paths], getFn, existed ?? undefined);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
109
196
|
get size(): number {
|
|
110
197
|
let count = 0;
|
|
111
198
|
for (const s of this.valueSubs.values()) count += s.size;
|
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
|
}
|
|
@@ -708,10 +784,14 @@ export class Transport {
|
|
|
708
784
|
case 'auth-verify': {
|
|
709
785
|
if (!self.options.keyAuth) return error('KeyAuth not configured', Errors.INTERNAL);
|
|
710
786
|
const result = self.options.keyAuth.verify(msg.publicKey, msg.signature, msg.nonce);
|
|
711
|
-
if (!result)
|
|
787
|
+
if (!result) {
|
|
788
|
+
self._log.warn(`Auth failed for key ${msg.publicKey?.slice(0, 16)}…`);
|
|
789
|
+
return error('Authentication failed', Errors.AUTH_REQUIRED);
|
|
790
|
+
}
|
|
712
791
|
// Also set ws auth context
|
|
713
792
|
const ctx = self.options.keyAuth.validateToken(result.token);
|
|
714
793
|
if (ctx) ws.data.auth = ctx as unknown as Record<string, unknown>;
|
|
794
|
+
self._log.info(`Auth succeeded: ${(ctx as any)?.fingerprint?.slice(0, 12) ?? 'unknown'}…`);
|
|
715
795
|
return reply(result);
|
|
716
796
|
}
|
|
717
797
|
case 'auth-link-device': {
|
|
@@ -856,6 +936,20 @@ export class Transport {
|
|
|
856
936
|
return reply(info);
|
|
857
937
|
}
|
|
858
938
|
|
|
939
|
+
case 'batch-sub': {
|
|
940
|
+
let count = 0;
|
|
941
|
+
for (const sub of msg.subscriptions) {
|
|
942
|
+
if (self.rules && !self.rules.check('read', sub.path, ws.data.auth)) continue;
|
|
943
|
+
if (ws.data.valueSubs.size + ws.data.childSubs.size >= self.options.maxSubscriptionsPerClient) break;
|
|
944
|
+
const bSubKey = `${sub.event}:${sub.path}`;
|
|
945
|
+
const subsMap = sub.event === 'value' ? ws.data.valueSubs : ws.data.childSubs;
|
|
946
|
+
if (subsMap.has(bSubKey)) { count++; continue; }
|
|
947
|
+
subsMap.set(bSubKey, self._addBroadcastSub(ws, sub.path, sub.event as 'value' | 'child'));
|
|
948
|
+
count++;
|
|
949
|
+
}
|
|
950
|
+
return reply({ subscribed: count });
|
|
951
|
+
}
|
|
952
|
+
|
|
859
953
|
case 'transform': {
|
|
860
954
|
const { path: tPath, type, value: tVal } = msg as any;
|
|
861
955
|
let sentinel: unknown;
|
|
@@ -887,11 +981,19 @@ export class Transport {
|
|
|
887
981
|
return reply(summary);
|
|
888
982
|
}
|
|
889
983
|
|
|
984
|
+
case 'admin-stats-toggle': {
|
|
985
|
+
const enable = msg.enabled;
|
|
986
|
+
if (enable) self.db.startStatsPublisher();
|
|
987
|
+
else self.db.stopStatsPublisher();
|
|
988
|
+
return reply({ enabled: self.db.statsEnabled });
|
|
989
|
+
}
|
|
990
|
+
|
|
890
991
|
default:
|
|
891
992
|
return error('Unknown operation', Errors.INTERNAL);
|
|
892
993
|
}
|
|
893
994
|
} catch (e: unknown) {
|
|
894
995
|
const message = e instanceof Error ? e.message : 'Internal error';
|
|
996
|
+
self._log.error(`op=${msg.op} path=${msg.path ?? '—'} error: ${message}`);
|
|
895
997
|
return error(message, Errors.INTERNAL);
|
|
896
998
|
}
|
|
897
999
|
},
|
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
|
@@ -65,7 +65,9 @@ export type ClientMessage =
|
|
|
65
65
|
| { id: string; op: 'transform'; path: string; type: string; value?: unknown }
|
|
66
66
|
| { id: string; op: 'set-ttl'; path: string; value: unknown; ttl: number }
|
|
67
67
|
| { id: string; op: 'sweep' }
|
|
68
|
-
| { id: string; op: 'get-rules' }
|
|
68
|
+
| { id: string; op: 'get-rules' }
|
|
69
|
+
| { id: string; op: 'batch-sub'; subscriptions: Array<{ path: string; event: SubEvent }> }
|
|
70
|
+
| { id: string; op: 'admin-stats-toggle'; enabled: boolean };
|
|
69
71
|
|
|
70
72
|
// Server → Client messages
|
|
71
73
|
export type ServerMessage =
|