@leftium/gg 0.0.49 → 0.0.51

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.
@@ -29,7 +29,7 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
29
29
  * A captured log entry from gg()
30
30
  */
31
31
  export interface CapturedEntry {
32
- /** Namespace (e.g., "gg:routes/+page.svelte@handleClick") */
32
+ /** Namespace (e.g., "routes/+page.svelte@handleClick") */
33
33
  namespace: string;
34
34
  /** Color assigned by the debug library (e.g., "#CC3366") */
35
35
  color: string;
@@ -59,6 +59,25 @@ export interface CapturedEntry {
59
59
  rows: Array<Record<string, unknown>>;
60
60
  };
61
61
  }
62
+ /**
63
+ * Tracks loggs dropped by the keep gate (Layer 1) for a single namespace.
64
+ * Maintained outside the ring buffer — does not consume buffer slots.
65
+ * The `preview` field holds the most recent dropped logg so the future
66
+ * sentinel UI can show what the namespace is producing right now.
67
+ */
68
+ export interface DroppedNamespaceInfo {
69
+ namespace: string;
70
+ /** Timestamp of the first dropped logg for this namespace */
71
+ firstSeen: number;
72
+ /** Timestamp of the most recent dropped logg */
73
+ lastSeen: number;
74
+ /** Total number of dropped loggs across all time */
75
+ total: number;
76
+ /** Count per logg type key ('log' for unlabelled calls, or 'debug'/'info'/'warn'/'error') */
77
+ byType: Record<string, number>;
78
+ /** Most recent dropped logg — overwritten on each drop, used for sentinel preview */
79
+ preview: CapturedEntry;
80
+ }
62
81
  /**
63
82
  * Eruda plugin interface
64
83
  */
@@ -28,9 +28,16 @@ export default function ggCallSitesPlugin(options = {}) {
28
28
  // Set a compile-time flag so gg() can detect the plugin is installed.
29
29
  // Vite replaces all occurrences of __GG_TAG_PLUGIN__ with true at build time,
30
30
  // before any code executes — no ordering issues.
31
+ // Alias GG_ENABLED → VITE_GG_ENABLED so users only need to set one variable.
32
+ // VITE_ prefix is required for client-side exposure via import.meta.env.
33
+ // If VITE_GG_ENABLED is already set explicitly, it takes precedence.
34
+ const ggEnabled = process.env.VITE_GG_ENABLED ?? process.env.GG_ENABLED;
31
35
  return {
32
36
  define: {
33
- __GG_TAG_PLUGIN__: 'true'
37
+ __GG_TAG_PLUGIN__: 'true',
38
+ ...(ggEnabled !== undefined && {
39
+ 'import.meta.env.VITE_GG_ENABLED': JSON.stringify(ggEnabled)
40
+ })
34
41
  }
35
42
  };
36
43
  },
@@ -735,7 +742,7 @@ export function transformGgCalls(code, shortPath, filePath, svelteInfo, jsFuncti
735
742
  if (code.slice(i + 2, i + 9) === '.here()') {
736
743
  const { line, col } = getLineCol(code, i);
737
744
  const fnName = getFunctionName(i, range);
738
- const callpoint = `${shortPath}${fnName ? `@${fnName}` : ''}`;
745
+ const callpoint = `gg:${shortPath}${fnName ? `@${fnName}` : ''}`;
739
746
  const escapedNs = escapeForString(callpoint);
740
747
  result.push(code.slice(lastIndex, i));
741
748
  result.push(`gg._here(${buildOptions(range, escapedNs, line, col)})`);
@@ -754,7 +761,7 @@ export function transformGgCalls(code, shortPath, filePath, svelteInfo, jsFuncti
754
761
  const openParenPos = i + methodCallLen - 1;
755
762
  const { line, col } = getLineCol(code, i);
756
763
  const fnName = getFunctionName(i, range);
757
- const callpoint = `${shortPath}${fnName ? `@${fnName}` : ''}`;
764
+ const callpoint = `gg:${shortPath}${fnName ? `@${fnName}` : ''}`;
758
765
  const escapedNs = escapeForString(callpoint);
759
766
  const closeParenPos = findMatchingParen(code, openParenPos);
760
767
  if (closeParenPos === -1) {
@@ -788,7 +795,7 @@ export function transformGgCalls(code, shortPath, filePath, svelteInfo, jsFuncti
788
795
  if (code[i + 2] === '(') {
789
796
  const { line, col } = getLineCol(code, i);
790
797
  const fnName = getFunctionName(i, range);
791
- const callpoint = `${shortPath}${fnName ? `@${fnName}` : ''}`;
798
+ const callpoint = `gg:${shortPath}${fnName ? `@${fnName}` : ''}`;
792
799
  const escapedNs = escapeForString(callpoint);
793
800
  const openParenPos = i + 2; // position of '(' in 'gg('
794
801
  // Find matching closing paren
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from 'vite';
2
+ export interface GgFileSinkOptions {
3
+ /** Directory to write log files into. Defaults to `.gg/` in the project root. */
4
+ dir?: string;
5
+ }
6
+ export default function ggFileSinkPlugin(options?: GgFileSinkOptions): Plugin;
@@ -0,0 +1,394 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { gg } from './gg.js';
4
+ import { matchesPattern } from './pattern.js';
5
+ /**
6
+ * Serialize a CapturedEntry for writing to the JSONL log file.
7
+ *
8
+ * IMPORTANT: The SSR injection string (in the `transform` hook below) and the
9
+ * browser injection string hand-roll the same logic as plain JS because injected
10
+ * code cannot import from this module. If you change the SerializedEntry schema
11
+ * (add/rename/remove fields), update both injection strings to match.
12
+ *
13
+ * Server path: `serializeEntry(entry, 'server')` → appended via configureServer listener
14
+ * SSR path: ssrInjection string (search for `__ggFileSinkServerWriter`)
15
+ * Browser path: injection string (search for `__ggFileSinkSender`)
16
+ */
17
+ function serializeEntry(entry, env, origin) {
18
+ const out = {
19
+ ns: entry.namespace,
20
+ msg: entry.message,
21
+ ts: entry.timestamp,
22
+ env,
23
+ diff: entry.diff
24
+ };
25
+ if (entry.level && entry.level !== 'debug')
26
+ out.lvl = entry.level;
27
+ if (origin)
28
+ out.origin = origin;
29
+ if (entry.file)
30
+ out.file = entry.file;
31
+ if (entry.line !== undefined)
32
+ out.line = entry.line;
33
+ if (entry.src)
34
+ out.src = entry.src;
35
+ if (entry.tableData)
36
+ out.table = entry.tableData;
37
+ return out;
38
+ }
39
+ /**
40
+ * Pre-dedup field filters: namespace glob and timestamp.
41
+ * Applied before dedup/mismatch so the index sees all entries for a call site.
42
+ */
43
+ function filterLinePreDedup(line, params) {
44
+ let entry;
45
+ try {
46
+ entry = JSON.parse(line);
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ const filter = params.get('filter');
52
+ if (filter && !matchesPattern(entry.ns, filter))
53
+ return false;
54
+ const since = params.get('since');
55
+ if (since && entry.ts < Number(since))
56
+ return false;
57
+ return true;
58
+ }
59
+ /**
60
+ * Post-dedup field filters: env and origin.
61
+ * Applied after dedup/mismatch so cross-env comparisons see both sides first.
62
+ * e.g. ?mismatch&env=server correctly returns the server half of mismatch pairs.
63
+ */
64
+ function filterEntryPostDedup(entry, params) {
65
+ const env = params.get('env');
66
+ if (env && entry.env !== env)
67
+ return false;
68
+ const origin = params.get('origin');
69
+ if (origin && entry.origin !== origin)
70
+ return false;
71
+ return true;
72
+ }
73
+ /**
74
+ * Dedup key for an entry: namespace + line number.
75
+ * Two entries with the same key are the "same call site" — server and client
76
+ * rendering the same gg() call. If their msg also matches they're identical;
77
+ * if msg differs they're a hydration mismatch.
78
+ */
79
+ function dedupKey(entry) {
80
+ return `${entry.ns}\0${entry.line ?? ''}`;
81
+ }
82
+ /**
83
+ * Apply dedup / mismatch logic to an already-field-filtered list of entries.
84
+ *
85
+ * Default (all=false, mismatch=false):
86
+ * Server entries always pass. A client entry is dropped when a server entry
87
+ * at the same [ns, line] produced the same msg (exact duplicate). Client
88
+ * entries at call sites with no server counterpart (onMount, event handlers)
89
+ * are kept. Client entries where msg differs from the server entry are kept —
90
+ * they surface as hydration mismatches alongside the server entry.
91
+ *
92
+ * all=true:
93
+ * No dedup. Every entry is returned as written.
94
+ *
95
+ * mismatch=true:
96
+ * Return only entries from call sites where BOTH envs exist AND msg differs.
97
+ * Entries from server-only or client-only call sites are suppressed.
98
+ */
99
+ function applyDedup(entries, all, mismatch) {
100
+ if (all)
101
+ return entries;
102
+ // Build index: dedupKey → { serverMsgs, clientMsgs }
103
+ const index = new Map();
104
+ for (const e of entries) {
105
+ const k = dedupKey(e);
106
+ if (!index.has(k))
107
+ index.set(k, { serverMsgs: new Set(), clientMsgs: new Set() });
108
+ const slot = index.get(k);
109
+ if (e.env === 'server')
110
+ slot.serverMsgs.add(e.msg);
111
+ else
112
+ slot.clientMsgs.add(e.msg);
113
+ }
114
+ if (mismatch) {
115
+ // Keep only entries from call sites where both envs exist and at least one
116
+ // msg is present in one env but not the other (i.e. any difference exists).
117
+ return entries.filter((e) => {
118
+ const slot = index.get(dedupKey(e));
119
+ if (slot.serverMsgs.size === 0 || slot.clientMsgs.size === 0)
120
+ return false;
121
+ // Check for any msg that exists on one side but not the other
122
+ for (const m of slot.serverMsgs)
123
+ if (!slot.clientMsgs.has(m))
124
+ return true;
125
+ for (const m of slot.clientMsgs)
126
+ if (!slot.serverMsgs.has(m))
127
+ return true;
128
+ return false;
129
+ });
130
+ }
131
+ // Default dedup: drop client entries that are exact duplicates of a server entry.
132
+ return entries.filter((e) => {
133
+ if (e.env !== 'client')
134
+ return true;
135
+ const slot = index.get(dedupKey(e));
136
+ if (!slot || slot.serverMsgs.size === 0)
137
+ return true; // no server counterpart — keep
138
+ return !slot.serverMsgs.has(e.msg); // keep only if msg differs (mismatch)
139
+ });
140
+ }
141
+ /**
142
+ * Collapse consecutive entries with the same ns+msg into a single entry with
143
+ * a `count` field. Mirrors the Chrome DevTools repeat-counter behaviour.
144
+ *
145
+ * Only consecutive runs are collapsed — intentional: an entry appearing again
146
+ * after different messages is a new event and should be shown separately.
147
+ *
148
+ * The `ts` and `diff` of the *first* occurrence are kept; `count` is omitted
149
+ * when it is 1 (no repetition) so the schema stays clean for non-repeated entries.
150
+ */
151
+ function collapseRepeats(entries) {
152
+ const out = [];
153
+ for (const e of entries) {
154
+ const prev = out.at(-1);
155
+ if (prev && prev.ns === e.ns && prev.msg === e.msg) {
156
+ prev.count = (prev.count ?? 1) + 1;
157
+ }
158
+ else {
159
+ out.push({ ...e });
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+ export default function ggFileSinkPlugin(options = {}) {
165
+ let logFile;
166
+ let serverSideListener = null;
167
+ let ggModulePath = '';
168
+ return {
169
+ name: 'gg-file-sink',
170
+ configResolved(config) {
171
+ // Resolve the absolute path to gg.ts — works both in this repo (src/lib/gg.ts)
172
+ // and in consumer projects where gg is in node_modules/@leftium/gg/src/lib/gg.ts.
173
+ // We try both locations; whichever resolves to an existing file wins.
174
+ const candidates = [
175
+ path.resolve(config.root, 'src/lib/gg.ts'),
176
+ path.resolve(config.root, 'node_modules/@leftium/gg/src/lib/gg.ts')
177
+ ];
178
+ ggModulePath = candidates.find((p) => fs.existsSync(p)) ?? candidates[0];
179
+ },
180
+ transform(code, id, transformOptions) {
181
+ if (id !== ggModulePath)
182
+ return null;
183
+ if (transformOptions?.ssr) {
184
+ // SSR injection: write server-side entries directly to the log file.
185
+ // Runs in Vite's SSR module runner (same Node.js process but separate module
186
+ // instance from configureServer, so we can't share a listener — inject instead).
187
+ // We pass appendFileSync + the log file path via globalThis so the injected
188
+ // code has no imports of its own (avoids TLA / static import constraints).
189
+ // Guarded by import.meta.env.DEV — tree-shaken in production builds.
190
+ // NOTE: this string mirrors serializeEntry() above — keep in sync if schema changes.
191
+ const ssrInjection = `
192
+ // gg-file-sink: server-side direct writer (injected by ggFileSinkPlugin)
193
+ if (import.meta.env.DEV && globalThis.__ggFileSink) {
194
+ const { appendFileSync: __ggAppendFileSync, logFile: __ggLogFile } = globalThis.__ggFileSink;
195
+ gg.addLogListener(function __ggFileSinkServerWriter(entry) {
196
+ if (!__ggLogFile) return;
197
+ const s = {
198
+ ns: entry.namespace,
199
+ msg: entry.message,
200
+ ts: entry.timestamp,
201
+ env: 'server',
202
+ diff: entry.diff,
203
+ };
204
+ if (entry.level && entry.level !== 'debug') s.lvl = entry.level;
205
+ if (entry.file) s.file = entry.file;
206
+ if (entry.line !== undefined) s.line = entry.line;
207
+ if (entry.src) s.src = entry.src;
208
+ if (entry.tableData) s.table = entry.tableData;
209
+ try { __ggAppendFileSync(__ggLogFile, JSON.stringify(s) + '\\n'); } catch {}
210
+ });
211
+ }
212
+ `;
213
+ return { code: code + ssrInjection, map: null };
214
+ }
215
+ // Browser injection: relay entries to Vite dev server via HMR WebSocket.
216
+ // Runs once when the gg module is first loaded in the browser.
217
+ // Guarded by import.meta.hot — Vite tree-shakes this in production builds.
218
+ // NOTE: this string mirrors serializeEntry() above — keep in sync if schema changes.
219
+ const injection = `
220
+ // gg-file-sink: client-side HMR sender (injected by ggFileSinkPlugin)
221
+ if (import.meta.hot) {
222
+ const __ggFileSinkOrigin =
223
+ typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
224
+ ? 'tauri'
225
+ : 'browser';
226
+ gg.addLogListener(function __ggFileSinkSender(entry) {
227
+ if (!import.meta.hot) return;
228
+ const s = {
229
+ ns: entry.namespace,
230
+ msg: entry.message,
231
+ ts: entry.timestamp,
232
+ env: 'client',
233
+ origin: __ggFileSinkOrigin,
234
+ diff: entry.diff,
235
+ };
236
+ if (entry.level && entry.level !== 'debug') s.lvl = entry.level;
237
+ if (entry.file) s.file = entry.file;
238
+ if (entry.line !== undefined) s.line = entry.line;
239
+ if (entry.src) s.src = entry.src;
240
+ if (entry.tableData) s.table = entry.tableData;
241
+ import.meta.hot.send('gg:log', { entry: s });
242
+ });
243
+ }
244
+ `;
245
+ return { code: code + injection, map: null };
246
+ },
247
+ configureServer(server) {
248
+ // Truncate/create log file once the actual port is known.
249
+ // appendEntry() guards with `if (!logFile) return` for the brief window
250
+ // before listening fires, so no entries are written to the wrong file.
251
+ server.httpServer?.once('listening', () => {
252
+ const addr = server.httpServer?.address();
253
+ const port = addr && typeof addr === 'object' ? addr.port : (server.config.server.port ?? 5173);
254
+ const dir = options.dir ? path.resolve(options.dir) : path.resolve(process.cwd(), '.gg');
255
+ fs.mkdirSync(dir, { recursive: true });
256
+ logFile = path.join(dir, `logs-${port}.jsonl`);
257
+ fs.writeFileSync(logFile, '');
258
+ });
259
+ // Expose appendFileSync + logFile path via globalThis so the SSR-injected
260
+ // listener (running in Vite's separate module runner context) can write to the
261
+ // same file without needing its own fs import.
262
+ globalThis.__ggFileSink = {
263
+ appendFileSync: fs.appendFileSync.bind(fs),
264
+ get logFile() {
265
+ return logFile;
266
+ }
267
+ };
268
+ const appendEntry = (serialized) => {
269
+ if (!logFile)
270
+ return;
271
+ fs.appendFileSync(logFile, JSON.stringify(serialized) + '\n');
272
+ };
273
+ // Client-side entries arrive via HMR custom event
274
+ server.hot.on('gg:log', (data) => {
275
+ if (!data?.entry)
276
+ return;
277
+ const serialized = {
278
+ ...data.entry,
279
+ env: 'client',
280
+ origin: data.entry.origin ?? 'browser'
281
+ };
282
+ appendEntry(serialized);
283
+ });
284
+ // Server-side entries: register listener on the gg module directly
285
+ serverSideListener = (entry) => {
286
+ appendEntry(serializeEntry(entry, 'server'));
287
+ };
288
+ gg.addLogListener(serverSideListener);
289
+ // Clean up on dev server close
290
+ server.httpServer?.once('close', () => {
291
+ if (serverSideListener) {
292
+ gg.removeLogListener(serverSideListener);
293
+ serverSideListener = null;
294
+ }
295
+ delete globalThis.__ggFileSink;
296
+ });
297
+ // /__gg/ index — JSON status for agents and developers
298
+ server.middlewares.use('/__gg', (req, res, next) => {
299
+ const pathname = new URL(req.url || '/', 'http://x').pathname;
300
+ // Only handle exact /__gg or /__gg/ — let other /__gg/* routes fall through
301
+ if (pathname !== '' && pathname !== '/')
302
+ return next();
303
+ if (req.method?.toUpperCase() !== 'GET')
304
+ return next();
305
+ let entries = 0;
306
+ try {
307
+ const content = fs.readFileSync(logFile, 'utf-8');
308
+ entries = content.split('\n').filter((l) => l.trim()).length;
309
+ }
310
+ catch {
311
+ // file not yet created — leave entries at 0
312
+ }
313
+ const port = (() => {
314
+ const addr = server.httpServer?.address();
315
+ return addr && typeof addr === 'object' ? addr.port : (server.config.server.port ?? 5173);
316
+ })();
317
+ const body = JSON.stringify({
318
+ plugin: 'gg-file-sink',
319
+ logFile: `.gg/logs-${port}.jsonl`,
320
+ entries,
321
+ endpoints: {
322
+ 'GET /__gg/logs': 'read deduplicated JSONL entries (?filter=, ?since=, ?env=, ?origin=, ?all, ?mismatch, ?raw)',
323
+ 'DELETE /__gg/logs': 'truncate log file',
324
+ 'GET /__gg/project-root': 'project root path'
325
+ }
326
+ }, null, 2);
327
+ res.statusCode = 200;
328
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
329
+ res.end(body);
330
+ });
331
+ // /__gg/logs middleware
332
+ server.middlewares.use('/__gg/logs', (req, res) => {
333
+ const method = req.method?.toUpperCase();
334
+ // HEAD: used by runGgDiagnostics() to detect plugin presence
335
+ if (method === 'HEAD') {
336
+ res.statusCode = 200;
337
+ res.end();
338
+ return;
339
+ }
340
+ if (method === 'DELETE') {
341
+ try {
342
+ fs.writeFileSync(logFile, '');
343
+ res.statusCode = 204;
344
+ res.end();
345
+ }
346
+ catch (err) {
347
+ res.statusCode = 500;
348
+ res.end(String(err));
349
+ }
350
+ return;
351
+ }
352
+ if (method === 'GET') {
353
+ try {
354
+ const params = new URL(req.url || '/', 'http://x').searchParams;
355
+ const all = params.has('all');
356
+ const mismatch = params.has('mismatch');
357
+ // ?raw disables consecutive-repeat collapsing (count field)
358
+ const noCollapse = params.has('raw');
359
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
360
+ const fileContent = fs.readFileSync(logFile, 'utf-8');
361
+ const lines = fileContent.split('\n').filter((l) => l.trim());
362
+ // Pre-dedup filters: namespace glob and timestamp (symmetric — don't affect cross-env index)
363
+ const preFiltered = lines.filter((l) => filterLinePreDedup(l, params));
364
+ // Parse surviving lines for dedup/mismatch pass
365
+ const entries = preFiltered.flatMap((l) => {
366
+ try {
367
+ return [JSON.parse(l)];
368
+ }
369
+ catch {
370
+ return [];
371
+ }
372
+ });
373
+ // Apply dedup / mismatch logic (default: dedup on)
374
+ const deduped = applyDedup(entries, all, mismatch);
375
+ // Post-dedup filters: env and origin (applied after so cross-env index is intact)
376
+ const postFiltered = deduped.filter((e) => filterEntryPostDedup(e, params));
377
+ // Collapse consecutive repeated messages (count field), unless ?raw
378
+ const result = noCollapse ? postFiltered : collapseRepeats(postFiltered);
379
+ res.statusCode = 200;
380
+ res.end(result.map((e) => JSON.stringify(e)).join('\n') + (result.length ? '\n' : ''));
381
+ }
382
+ catch (err) {
383
+ res.statusCode = 500;
384
+ res.end(String(err));
385
+ }
386
+ return;
387
+ }
388
+ res.statusCode = 405;
389
+ res.setHeader('Allow', 'GET, HEAD, DELETE');
390
+ res.end('Method Not Allowed');
391
+ });
392
+ }
393
+ };
394
+ }
package/dist/gg.d.ts CHANGED
@@ -208,7 +208,10 @@ export declare function underline(): ChainableColorFn;
208
208
  */
209
209
  export declare function dim(): ChainableColorFn;
210
210
  export declare namespace gg {
211
+ /** @deprecated Use gg.addLogListener / gg.removeLogListener instead */
211
212
  let _onLog: OnLogCallback | null;
213
+ let addLogListener: (callback: OnLogCallback) => void;
214
+ let removeLogListener: (callback: OnLogCallback) => void;
212
215
  let _ns: (options: {
213
216
  ns: string;
214
217
  file?: string;