@spooky-sync/core 0.0.1-canary.290 → 0.0.1-canary.291

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.
@@ -3,21 +3,202 @@ import type { PinoTransmit } from '../../types';
3
3
 
4
4
  export type Logger = PinoLogger;
5
5
 
6
- export function createLogger(level: Level = 'info', transmit?: PinoTransmit): Logger {
6
+ /** One line the logger wrote, as the DevTools Logs tab and the MCP read it. */
7
+ export interface CapturedLog {
8
+ /** Monotonic for the page's lifetime; a reader resumes after the last one it holds. */
9
+ seq: number;
10
+ time: number;
11
+ /** Pino's numeric level: 10 trace … 60 fatal. */
12
+ level: number;
13
+ msg: string;
14
+ /** The whole log object as JSON (what the console printed), capped at {@link LogTap.LINE_MAX}. */
15
+ line: string;
16
+ }
17
+
18
+ export interface LogRead {
19
+ entries: CapturedLog[];
20
+ /** `seq` of the newest captured line; pass it back as `after` to resume. */
21
+ head: number;
22
+ /** Lines after `after` the ring overwrote before they were read (cleared ones excluded). */
23
+ dropped: number;
24
+ consoleLevel: Level;
25
+ captureLevel: Level;
26
+ }
27
+
28
+ const LEVELS = pino.levels.values as Record<Level, number>;
29
+ const CAPTURE_KEY = 'sp00ky:devtools:logCapture';
30
+
31
+ function isLevel(value: unknown): value is Level {
32
+ return typeof value === 'string' && value in LEVELS;
33
+ }
34
+
35
+ /**
36
+ * JSON for a log object that can never throw into the call site: Errors keep
37
+ * their message and stack (plain `JSON.stringify` prints `{}`), bigints become
38
+ * strings, and a cycle prints `[Circular]` instead of throwing.
39
+ */
40
+ export function stringifyLog(o: unknown): string {
41
+ const ancestors: unknown[] = [];
42
+ try {
43
+ return JSON.stringify(o, function (this: unknown, _key: string, value: unknown) {
44
+ if (typeof value === 'bigint') return value.toString();
45
+ if (value instanceof Error) return { type: value.name, message: value.message, stack: value.stack };
46
+ if (typeof value !== 'object' || value === null) return value;
47
+ while (ancestors.length > 0 && ancestors.at(-1) !== this) ancestors.pop();
48
+ if (ancestors.includes(value)) return '[Circular]';
49
+ ancestors.push(value);
50
+ return value;
51
+ });
52
+ } catch {
53
+ const msg = (o as { msg?: unknown } | null)?.msg;
54
+ return JSON.stringify({ msg: typeof msg === 'string' ? msg : '[unserializable log object]' });
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The page-side log buffer behind the DevTools Logs tab. Always on and
60
+ * bounded: a line costs the JSON the console write produces anyway, and a
61
+ * panel opened after boot still sees the boot.
62
+ *
63
+ * It also owns the logger's level. The console prints at the configured level
64
+ * only; `setCaptureLevel` changes what is RECORDED (debug without flooding the
65
+ * console), live on every child logger, and keeps it for this tab's session so
66
+ * a reload captures its boot at that level too.
67
+ */
68
+ export class LogTap {
69
+ static readonly CAPACITY = 500;
70
+ static readonly LINE_MAX = 4000;
71
+
72
+ private readonly ring: CapturedLog[] = [];
73
+ private seq = 0;
74
+ /** `seq` at the last `clear()`: lines up to here are gone on purpose, not dropped. */
75
+ private clearedThrough = 0;
76
+ private readonly listeners = new Set<() => void>();
77
+ private readonly loggers: Array<WeakRef<Logger>> = [];
78
+ private captureLevelValue: Level;
79
+
80
+ constructor(readonly consoleLevel: Level) {
81
+ this.captureLevelValue = consoleLevel;
82
+ try {
83
+ const saved = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(CAPTURE_KEY) : null;
84
+ if (isLevel(saved)) this.captureLevelValue = saved;
85
+ } catch {
86
+ // Storage blocked: capture at the console level.
87
+ }
88
+ }
89
+
90
+ /** `seq` of the newest captured line. */
91
+ get head(): number {
92
+ return this.seq;
93
+ }
94
+
95
+ get captureLevel(): Level {
96
+ return this.captureLevelValue;
97
+ }
98
+
99
+ /** What pino has to produce for both the console and the buffer. */
100
+ get effectiveLevel(): Level {
101
+ return LEVELS[this.captureLevelValue] < LEVELS[this.consoleLevel] ? this.captureLevelValue : this.consoleLevel;
102
+ }
103
+
104
+ /** Whether the console prints a line at this numeric level. */
105
+ printsToConsole(level: number): boolean {
106
+ return level >= LEVELS[this.consoleLevel];
107
+ }
108
+
109
+ /** Remember a logger (the root and every child) so a level change reaches it. */
110
+ track(logger: Logger): void {
111
+ this.loggers.push(new WeakRef(logger));
112
+ }
113
+
114
+ setCaptureLevel(level: Level): void {
115
+ if (!isLevel(level)) throw new Error(`unknown log level: ${String(level)}`);
116
+ this.captureLevelValue = level;
117
+ try {
118
+ if (typeof sessionStorage !== 'undefined') {
119
+ if (level === this.consoleLevel) sessionStorage.removeItem(CAPTURE_KEY);
120
+ else sessionStorage.setItem(CAPTURE_KEY, level);
121
+ }
122
+ } catch {
123
+ // Storage blocked: the change holds until the page goes away.
124
+ }
125
+ const effective = this.effectiveLevel;
126
+ for (let i = this.loggers.length - 1; i >= 0; i--) {
127
+ const logger = this.loggers[i].deref();
128
+ if (logger) logger.level = effective;
129
+ else this.loggers.splice(i, 1);
130
+ }
131
+ }
132
+
133
+ record(o: Record<string, unknown>, line: string): void {
134
+ const level = typeof o.level === 'number' ? o.level : LEVELS.info;
135
+ if (level < LEVELS[this.captureLevelValue]) return;
136
+ this.ring.push({
137
+ seq: ++this.seq,
138
+ time: typeof o.time === 'number' ? o.time : Date.now(),
139
+ level,
140
+ msg: typeof o.msg === 'string' ? o.msg : '',
141
+ line: line.length > LogTap.LINE_MAX ? `${line.slice(0, LogTap.LINE_MAX)}…` : line,
142
+ });
143
+ if (this.ring.length > LogTap.CAPACITY) this.ring.shift();
144
+ for (const cb of this.listeners) {
145
+ try {
146
+ cb();
147
+ } catch {
148
+ // A listener must never turn a log call into a throw.
149
+ }
150
+ }
151
+ }
152
+
153
+ /** Lines after `after` (0 = everything still buffered), oldest first. */
154
+ read(after = 0, limit = LogTap.CAPACITY): LogRead {
155
+ const first = this.ring[0]?.seq ?? this.seq + 1;
156
+ const fresh = this.ring.filter((e) => e.seq > after);
157
+ return {
158
+ entries: fresh.slice(Math.max(0, fresh.length - limit)),
159
+ head: this.seq,
160
+ dropped: Math.max(0, first - Math.max(after, this.clearedThrough) - 1),
161
+ consoleLevel: this.consoleLevel,
162
+ captureLevel: this.captureLevelValue,
163
+ };
164
+ }
165
+
166
+ clear(): void {
167
+ this.ring.length = 0;
168
+ this.clearedThrough = this.seq;
169
+ }
170
+
171
+ /** Called synchronously inside the log call: keep it cheap, never log from it. */
172
+ subscribe(cb: () => void): () => void {
173
+ this.listeners.add(cb);
174
+ return () => void this.listeners.delete(cb);
175
+ }
176
+ }
177
+
178
+ export function createLogger(level: Level = 'info', transmit?: PinoTransmit, tap?: LogTap): Logger {
7
179
  const browserConfig: LoggerOptions['browser'] = {
8
180
  asObject: true,
9
181
  write: (o: any) => {
10
- // oxlint-disable-next-line no-console
11
- console.log(JSON.stringify(o));
182
+ const line = stringifyLog(o);
183
+ if (!tap || tap.printsToConsole(o?.level)) {
184
+ // oxlint-disable-next-line no-console
185
+ console.log(line);
186
+ }
187
+ tap?.record(o, line);
12
188
  },
13
189
  };
14
190
 
15
191
  if (transmit) {
16
- browserConfig.transmit = transmit;
192
+ // Pinned to the configured level: a lower capture level for DevTools must
193
+ // not start shipping debug lines to the collector.
194
+ browserConfig.transmit = { ...transmit, level: transmit.level ?? level };
17
195
  }
18
196
 
19
- return pino({
20
- level,
197
+ const logger = pino({
198
+ level: tap ? tap.effectiveLevel : level,
21
199
  browser: browserConfig,
200
+ onChild: (child) => tap?.track(child),
22
201
  });
202
+ tap?.track(logger);
203
+ return logger;
23
204
  }
@@ -8,12 +8,26 @@ const noop = () => {};
8
8
  * members the facade touches at construction / close time exist; sagas never
9
9
  * see these (they go through the adapters' `service` calls).
10
10
  */
11
- export function fakeServiceBundle<S extends SchemaStructure>(over: Partial<Services<S>> = {}): Services<S> & { authListeners: Array<(u: string | null) => void>; connectionListeners: Array<(s: string) => void>; receivers: unknown[] } {
11
+ export function fakeServiceBundle<S extends SchemaStructure>(over: Partial<Services<S>> = {}): Services<S> & {
12
+ authListeners: Array<(u: string | null) => void>;
13
+ connectionListeners: Array<(s: string) => void>;
14
+ receivers: unknown[];
15
+ /** Fire a store event (`DATABASE_LOCAL_QUERY`, ...) at whoever subscribed. */
16
+ emitStoreEvent: (type: string, payload: unknown) => void;
17
+ } {
12
18
  const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop, child: () => logger };
13
19
  const authListeners: Array<(u: string | null) => void> = [];
14
20
  const connectionListeners: Array<(s: string) => void> = [];
15
21
  const receivers: unknown[] = [];
16
- const events = { subscribe: noop };
22
+ const storeListeners = new Map<string, Array<(e: { payload: unknown }) => void>>();
23
+ const events = {
24
+ subscribe: (type: string, cb: (e: { payload: unknown }) => void) => {
25
+ storeListeners.set(type, [...(storeListeners.get(type) ?? []), cb]);
26
+ },
27
+ };
28
+ const emitStoreEvent = (type: string, payload: unknown) => {
29
+ for (const cb of storeListeners.get(type) ?? []) cb({ payload });
30
+ };
17
31
  const bundle = {
18
32
  logger,
19
33
  local: { getEvents: () => events, getClient: () => 'local-client', storageHealth: undefined, subscribeToStorageHealth: undefined, close: async () => undefined } as any,
@@ -30,5 +44,5 @@ export function fakeServiceBundle<S extends SchemaStructure>(over: Partial<Servi
30
44
  tabId: 'tab-test',
31
45
  ...over,
32
46
  } as Services<S>;
33
- return Object.assign(bundle, { authListeners, connectionListeners, receivers });
47
+ return Object.assign(bundle, { authListeners, connectionListeners, receivers, emitStoreEvent });
34
48
  }