@leftium/gg 0.0.50 → 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.
- package/README.md +108 -2
- package/dist/debug/browser.d.ts +1 -1
- package/dist/debug/browser.js +14 -6
- package/dist/debug/node.js +5 -3
- package/dist/eruda/buffer.d.ts +4 -0
- package/dist/eruda/buffer.js +16 -0
- package/dist/eruda/loader.js +58 -0
- package/dist/eruda/plugin.d.ts +5 -1
- package/dist/eruda/plugin.js +1219 -379
- package/dist/eruda/types.d.ts +20 -1
- package/dist/gg-call-sites-plugin.js +11 -4
- package/dist/gg-file-sink-plugin.d.ts +6 -0
- package/dist/gg-file-sink-plugin.js +394 -0
- package/dist/gg.d.ts +3 -0
- package/dist/gg.js +140 -38
- package/dist/open-in-editor.js +1 -1
- package/dist/pattern.d.ts +23 -0
- package/dist/pattern.js +41 -0
- package/dist/vite.d.ts +12 -2
- package/dist/vite.js +7 -1
- package/package.json +17 -17
package/dist/eruda/types.d.ts
CHANGED
|
@@ -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., "
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,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;
|