@leftium/gg 0.0.52 → 0.0.54

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.
@@ -1,5 +1,6 @@
1
1
  import { BROWSER } from 'esm-env';
2
2
  import { shouldLoadEruda, loadEruda } from './loader.js';
3
+ import { enableGgRuntime } from '../gg.js';
3
4
  let initialized = false;
4
5
  /**
5
6
  * Initialize the gg Eruda plugin
@@ -62,16 +63,11 @@ function setupGestureDetection(options) {
62
63
  // Reset timer on each tap
63
64
  if (tapTimer)
64
65
  clearTimeout(tapTimer);
65
- // If 5 taps detected, load Eruda
66
+ // If 5 taps detected, enable gg logging and load Eruda
66
67
  if (tapCount >= 5) {
67
- console.log('[gg] 5 taps detected, loading Eruda...');
68
- // Persist the decision
69
- try {
70
- localStorage.setItem('gg-enabled', 'true');
71
- }
72
- catch {
73
- // localStorage might not be available
74
- }
68
+ console.log('[gg] 5 taps detected, enabling gg logging and loading Eruda (session-only). For persistent logging across app restarts, run: localStorage.setItem("gg-enabled", "true")');
69
+ // Enable gg runtime for this session (flips ggConfig.enabled in memory only)
70
+ enableGgRuntime();
75
71
  loadEruda(options);
76
72
  resetTaps();
77
73
  return;
@@ -5,14 +5,10 @@ import { matchesPattern } from './pattern.js';
5
5
  /**
6
6
  * Serialize a CapturedEntry for writing to the JSONL log file.
7
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`)
8
+ * Used by:
9
+ * - configureServer listener (plugin's own gg instance)
10
+ * - globalThis.__ggFileSink.write() (SSR gg instances via self-registration)
11
+ * - Virtual module sender mirrors this schema as inline JS (search for `__ggFileSinkSender`)
16
12
  */
17
13
  function serializeEntry(entry, env, origin) {
18
14
  const out = {
@@ -20,7 +16,7 @@ function serializeEntry(entry, env, origin) {
20
16
  msg: entry.message,
21
17
  ts: entry.timestamp,
22
18
  env,
23
- diff: entry.diff
19
+ diff: entry.diff,
24
20
  };
25
21
  if (entry.level && entry.level !== 'debug')
26
22
  out.lvl = entry.level;
@@ -161,88 +157,97 @@ function collapseRepeats(entries) {
161
157
  }
162
158
  return out;
163
159
  }
160
+ /**
161
+ * Virtual module ID for the browser-side file sink sender.
162
+ *
163
+ * Virtual modules go through Vite's normal transform pipeline (NOT pre-bundled
164
+ * by esbuild), so `import.meta.hot` is available. This solves the fundamental
165
+ * problem: code inside pre-bundled deps (like gg.js) cannot use import.meta.hot
166
+ * because esbuild evaluates `typeof import.meta.hot` as "undefined" during
167
+ * dep optimization and tree-shakes the entire block.
168
+ *
169
+ * The virtual module is imported via a <script type="module"> tag injected by
170
+ * the `transformIndexHtml` hook below.
171
+ */
172
+ const VIRTUAL_MODULE_ID = 'virtual:gg-file-sink-sender';
173
+ const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
164
174
  export default function ggFileSinkPlugin(options = {}) {
165
175
  let logFile;
166
176
  let serverSideListener = null;
167
- let ggModulePath = '';
168
177
  return {
169
178
  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
+ // Virtual module: resolve and load the browser-side HMR sender.
180
+ resolveId(id) {
181
+ if (id === VIRTUAL_MODULE_ID)
182
+ return RESOLVED_VIRTUAL_MODULE_ID;
179
183
  },
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.DEVtree-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
- });
184
+ load(id) {
185
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
186
+ // This code runs through Vite's normal transform pipeline (not
187
+ // pre-bundled), so import.meta.hot is properly available.
188
+ //
189
+ // Uses a dual strategy for sending log entries to the dev server:
190
+ // 1. import.meta.hot.send() via HMR WebSocket (fast, no HTTP overhead)
191
+ // 2. fetch() POST to /__gg/logs as fallback (works even if HMR is unavailable)
192
+ //
193
+ // NOTE: this string mirrors serializeEntry() above keep in sync.
194
+ return `
195
+ import { gg } from '@leftium/gg';
196
+
197
+ const origin =
198
+ typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
199
+ ? 'tauri'
200
+ : 'browser';
201
+
202
+ // Batch entries and flush via fetch POST — works regardless of HMR state.
203
+ let __ggPendingEntries = [];
204
+ let __ggFlushTimer = null;
205
+ function __ggFlushEntries() {
206
+ __ggFlushTimer = null;
207
+ if (__ggPendingEntries.length === 0) return;
208
+ const batch = __ggPendingEntries;
209
+ __ggPendingEntries = [];
210
+ const body = batch.map(e => JSON.stringify(e)).join('\\n');
211
+ fetch('/__gg/logs', { method: 'POST', body, headers: { 'Content-Type': 'text/plain' } }).catch(() => {});
211
212
  }
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;
213
+
214
+ gg.addLogListener(function __ggFileSinkSender(entry) {
215
+ const s = {
216
+ ns: entry.namespace,
217
+ msg: entry.message,
218
+ ts: entry.timestamp,
219
+ env: 'client',
220
+ origin,
221
+ diff: entry.diff,
222
+ };
223
+ if (entry.level && entry.level !== 'debug') s.lvl = entry.level;
224
+ if (entry.file) s.file = entry.file;
225
+ if (entry.line !== undefined) s.line = entry.line;
226
+ if (entry.src) s.src = entry.src;
227
+ if (entry.tableData) s.table = entry.tableData;
228
+
229
+ // Try HMR first (lowest latency), fall back to batched fetch
230
+ if (import.meta.hot) {
241
231
  import.meta.hot.send('gg:log', { entry: s });
242
- });
243
- }
232
+ } else {
233
+ __ggPendingEntries.push(s);
234
+ if (!__ggFlushTimer) __ggFlushTimer = setTimeout(__ggFlushEntries, 100);
235
+ }
236
+ });
244
237
  `;
245
- return { code: code + injection, map: null };
238
+ }
239
+ },
240
+ // Inject the virtual module into the HTML page so it runs in the browser.
241
+ // Uses both transformIndexHtml (plain Vite apps) and a response-intercepting
242
+ // middleware (SvelteKit and other frameworks that bypass Vite's HTML pipeline).
243
+ transformIndexHtml() {
244
+ return [
245
+ {
246
+ tag: 'script',
247
+ attrs: { type: 'module', src: `/@id/${VIRTUAL_MODULE_ID}` },
248
+ injectTo: 'head',
249
+ },
250
+ ];
246
251
  },
247
252
  configureServer(server) {
248
253
  // Truncate/create log file once the actual port is known.
@@ -250,26 +255,29 @@ if (import.meta.hot) {
250
255
  // before listening fires, so no entries are written to the wrong file.
251
256
  server.httpServer?.once('listening', () => {
252
257
  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');
258
+ const port = addr && typeof addr === 'object'
259
+ ? addr.port
260
+ : (server.config.server.port ?? 5173);
261
+ const dir = options.dir
262
+ ? path.resolve(options.dir)
263
+ : path.resolve(process.cwd(), '.gg');
255
264
  fs.mkdirSync(dir, { recursive: true });
256
265
  logFile = path.join(dir, `logs-${port}.jsonl`);
257
266
  fs.writeFileSync(logFile, '');
258
267
  });
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
268
  const appendEntry = (serialized) => {
269
269
  if (!logFile)
270
270
  return;
271
271
  fs.appendFileSync(logFile, JSON.stringify(serialized) + '\n');
272
272
  };
273
+ // Expose a write() function via globalThis so ANY gg module instance
274
+ // (SSR, pre-bundled, monorepo-hoisted) can self-register a listener
275
+ // that writes to the log file. This replaces the broken transform hook.
276
+ globalThis.__ggFileSink = {
277
+ write(entry, env, origin) {
278
+ appendEntry(serializeEntry(entry, env, origin));
279
+ },
280
+ };
273
281
  // Client-side entries arrive via HMR custom event
274
282
  server.hot.on('gg:log', (data) => {
275
283
  if (!data?.entry)
@@ -277,7 +285,7 @@ if (import.meta.hot) {
277
285
  const serialized = {
278
286
  ...data.entry,
279
287
  env: 'client',
280
- origin: data.entry.origin ?? 'browser'
288
+ origin: data.entry.origin ?? 'browser',
281
289
  };
282
290
  appendEntry(serialized);
283
291
  });
@@ -297,6 +305,29 @@ if (import.meta.hot) {
297
305
  // /__gg/ index — JSON status for agents and developers
298
306
  server.middlewares.use('/__gg', (req, res, next) => {
299
307
  const pathname = new URL(req.url || '/', 'http://x').pathname;
308
+ // /__gg/stack — dump Connect middleware stack for debugging
309
+ if (pathname === '/stack') {
310
+ const stack = server.middlewares.stack;
311
+ const routes = stack.map((layer, i) => ({
312
+ i,
313
+ route: layer.route,
314
+ name: layer.handle?.name || '(anonymous)',
315
+ }));
316
+ res.writeHead(200, { 'Content-Type': 'application/json' });
317
+ res.end(JSON.stringify(routes, null, 2));
318
+ return;
319
+ }
320
+ // /__gg/sender — redirect to the virtual module URL so Vite's
321
+ // normal transform pipeline handles it (including HMR injection).
322
+ // Direct transformRequest() fails in consumer apps because the
323
+ // module graph hasn't loaded the virtual module yet at request time.
324
+ if (pathname === '/sender') {
325
+ res.writeHead(302, {
326
+ Location: `/@id/${VIRTUAL_MODULE_ID}`,
327
+ });
328
+ res.end();
329
+ return;
330
+ }
300
331
  // Only handle exact /__gg or /__gg/ — let other /__gg/* routes fall through
301
332
  if (pathname !== '' && pathname !== '/')
302
333
  return next();
@@ -312,7 +343,9 @@ if (import.meta.hot) {
312
343
  }
313
344
  const port = (() => {
314
345
  const addr = server.httpServer?.address();
315
- return addr && typeof addr === 'object' ? addr.port : (server.config.server.port ?? 5173);
346
+ return addr && typeof addr === 'object'
347
+ ? addr.port
348
+ : (server.config.server.port ?? 5173);
316
349
  })();
317
350
  const body = JSON.stringify({
318
351
  plugin: 'gg-file-sink',
@@ -321,8 +354,8 @@ if (import.meta.hot) {
321
354
  endpoints: {
322
355
  'GET /__gg/logs': 'read deduplicated JSONL entries (?filter=, ?since=, ?env=, ?origin=, ?all, ?mismatch, ?raw)',
323
356
  'DELETE /__gg/logs': 'truncate log file',
324
- 'GET /__gg/project-root': 'project root path'
325
- }
357
+ 'GET /__gg/project-root': 'project root path',
358
+ },
326
359
  }, null, 2);
327
360
  res.statusCode = 200;
328
361
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
@@ -337,6 +370,31 @@ if (import.meta.hot) {
337
370
  res.end();
338
371
  return;
339
372
  }
373
+ // POST: receive client-side log entries via fetch (fallback for HMR)
374
+ if (method === 'POST') {
375
+ let body = '';
376
+ req.on('data', (chunk) => {
377
+ body += chunk.toString();
378
+ });
379
+ req.on('end', () => {
380
+ try {
381
+ const lines = body.split('\n').filter((l) => l.trim());
382
+ for (const line of lines) {
383
+ const entry = JSON.parse(line);
384
+ entry.env = 'client';
385
+ entry.origin = entry.origin ?? 'browser';
386
+ appendEntry(entry);
387
+ }
388
+ res.statusCode = 204;
389
+ res.end();
390
+ }
391
+ catch (err) {
392
+ res.statusCode = 400;
393
+ res.end(String(err));
394
+ }
395
+ });
396
+ return;
397
+ }
340
398
  if (method === 'DELETE') {
341
399
  try {
342
400
  fs.writeFileSync(logFile, '');
@@ -358,7 +416,9 @@ if (import.meta.hot) {
358
416
  const noCollapse = params.has('raw');
359
417
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
360
418
  const fileContent = fs.readFileSync(logFile, 'utf-8');
361
- const lines = fileContent.split('\n').filter((l) => l.trim());
419
+ const lines = fileContent
420
+ .split('\n')
421
+ .filter((l) => l.trim());
362
422
  // Pre-dedup filters: namespace glob and timestamp (symmetric — don't affect cross-env index)
363
423
  const preFiltered = lines.filter((l) => filterLinePreDedup(l, params));
364
424
  // Parse surviving lines for dedup/mismatch pass
@@ -375,9 +435,12 @@ if (import.meta.hot) {
375
435
  // Post-dedup filters: env and origin (applied after so cross-env index is intact)
376
436
  const postFiltered = deduped.filter((e) => filterEntryPostDedup(e, params));
377
437
  // Collapse consecutive repeated messages (count field), unless ?raw
378
- const result = noCollapse ? postFiltered : collapseRepeats(postFiltered);
438
+ const result = noCollapse
439
+ ? postFiltered
440
+ : collapseRepeats(postFiltered);
379
441
  res.statusCode = 200;
380
- res.end(result.map((e) => JSON.stringify(e)).join('\n') + (result.length ? '\n' : ''));
442
+ res.end(result.map((e) => JSON.stringify(e)).join('\n') +
443
+ (result.length ? '\n' : ''));
381
444
  }
382
445
  catch (err) {
383
446
  res.statusCode = 500;
@@ -389,6 +452,6 @@ if (import.meta.hot) {
389
452
  res.setHeader('Allow', 'GET, HEAD, DELETE');
390
453
  res.end('Method Not Allowed');
391
454
  });
392
- }
455
+ },
393
456
  };
394
457
  }
package/dist/gg.d.ts CHANGED
@@ -91,6 +91,20 @@ export declare class GgChain<T> {
91
91
  /** Flush the log immediately and return the passthrough value. */
92
92
  get v(): T;
93
93
  }
94
+ /**
95
+ * Enable gg logging at runtime (e.g., after 5-tap gesture in production).
96
+ * Flips the internal enabled flag so subsequent gg() calls start capturing.
97
+ * This is session-only — does not persist to localStorage, so the next
98
+ * page load / app launch starts with gg disabled again.
99
+ *
100
+ * To enable gg across app restarts (e.g., in a prod Tauri app), run this
101
+ * in the Eruda console instead:
102
+ * localStorage.setItem("gg-enabled", "true")
103
+ * Clear with: localStorage.removeItem("gg-enabled") or gg.clearPersist()
104
+ *
105
+ * @internal Used by the Eruda gesture handler — not part of the public API.
106
+ */
107
+ export declare function enableGgRuntime(): void;
94
108
  /**
95
109
  * Chainable wrapper returned by gg.time(). Only supports .ns() for setting
96
110
  * the namespace for the entire timer group (inherited by timeLog/timeEnd).
package/dist/gg.js CHANGED
@@ -71,7 +71,8 @@ function getServerPort() {
71
71
  return new Promise((resolve) => {
72
72
  if (BROWSER) {
73
73
  // Browser environment
74
- const currentPort = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
74
+ const currentPort = window.location.port ||
75
+ (window.location.protocol === 'https:' ? '443' : '80');
75
76
  // Resolve the promise with the detected port
76
77
  resolve(currentPort);
77
78
  }
@@ -165,7 +166,7 @@ const ggConfig = {
165
166
  // filename B : http://localhost:5173/src/lib/gg.ts
166
167
  // srcRootprefix : http://localhost:5173/src/
167
168
  // <folderName> group: src
168
- srcRootPattern: '.*?(/(?<folderName>src|chunks)/)'
169
+ srcRootPattern: '.*?(/(?<folderName>src|chunks)/)',
169
170
  };
170
171
  const srcRootRegex = new RegExp(ggConfig.srcRootPattern, 'i');
171
172
  // To maintain unique millisecond diffs for each callpoint:
@@ -240,12 +241,16 @@ gg.here = () => {
240
241
  const namespace = callpoint;
241
242
  // Log the call-site info
242
243
  const ggLogFunction = namespaceToLogFunction.get(namespace) ||
243
- namespaceToLogFunction.set(namespace, createGgDebugger(namespace)).get(namespace);
244
+ namespaceToLogFunction
245
+ .set(namespace, createGgDebugger(namespace))
246
+ .get(namespace);
244
247
  ggLogFunction(` 📝 ${callpoint}`);
245
248
  return {
246
249
  fileName: callpoint,
247
- functionName: callpoint.includes('@') ? callpoint.split('@').pop() || '' : '',
248
- url: ''
250
+ functionName: callpoint.includes('@')
251
+ ? callpoint.split('@').pop() || ''
252
+ : '',
253
+ url: '',
249
254
  };
250
255
  };
251
256
  /**
@@ -424,7 +429,10 @@ function formatValue(v) {
424
429
  /** JSON replacer that limits nesting depth to avoid huge output. */
425
430
  function jsonReplacer(key, value) {
426
431
  // 'this' is the parent object; key === '' at the root level
427
- if (key !== '' && typeof value === 'object' && value !== null && !Array.isArray(value)) {
432
+ if (key !== '' &&
433
+ typeof value === 'object' &&
434
+ value !== null &&
435
+ !Array.isArray(value)) {
428
436
  // Count depth by checking how many ancestors are objects/arrays
429
437
  // Simple approximation: truncate any nested object at depth > 2
430
438
  const str = JSON.stringify(value);
@@ -434,7 +442,7 @@ function jsonReplacer(key, value) {
434
442
  return value;
435
443
  }
436
444
  function ggLog(options, ...args) {
437
- const { ns: nsLabel, file, line, col, src, level, stack, tableData } = options;
445
+ const { ns: nsLabel, file, line, col, src, level, stack, tableData, } = options;
438
446
  if (!ggConfig.enabled) {
439
447
  return args.length ? args[0] : { fileName: '', functionName: '', url: '' };
440
448
  }
@@ -443,12 +451,20 @@ function ggLog(options, ...args) {
443
451
  maxCallpointLength = namespace.length;
444
452
  }
445
453
  const ggLogFunction = namespaceToLogFunction.get(namespace) ||
446
- namespaceToLogFunction.set(namespace, createGgDebugger(namespace)).get(namespace);
454
+ namespaceToLogFunction
455
+ .set(namespace, createGgDebugger(namespace))
456
+ .get(namespace);
447
457
  // Prepare args for logging (console output is value-only; src is carried
448
458
  // on CapturedEntry for the Eruda UI to display on hover)
449
459
  const logArgs = args.length === 0 ? ['(no args)'] : [...args];
450
460
  // Add level prefix emoji for info/warn/error
451
- const levelEmoji = level === 'info' ? 'ℹ️' : level === 'warn' ? '⚠️' : level === 'error' ? '⛔' : '';
461
+ const levelEmoji = level === 'info'
462
+ ? 'ℹ️'
463
+ : level === 'warn'
464
+ ? '⚠️'
465
+ : level === 'error'
466
+ ? '⛔'
467
+ : '';
452
468
  if (levelEmoji) {
453
469
  if (typeof logArgs[0] === 'string') {
454
470
  logArgs[0] = `${levelEmoji} ${logArgs[0]}`;
@@ -478,7 +494,9 @@ function ggLog(options, ...args) {
478
494
  namespace,
479
495
  color: ggLogFunction.color,
480
496
  diff,
481
- message: logArgs.length === 1 ? formatValue(logArgs[0]) : logArgs.map(formatValue).join(' '),
497
+ message: logArgs.length === 1
498
+ ? formatValue(logArgs[0])
499
+ : logArgs.map(formatValue).join(' '),
482
500
  args: logArgs,
483
501
  timestamp: Date.now(),
484
502
  file,
@@ -487,7 +505,7 @@ function ggLog(options, ...args) {
487
505
  src,
488
506
  level,
489
507
  stack,
490
- tableData
508
+ tableData,
491
509
  };
492
510
  // Always buffer — earlyLogBuffer is a persistent replay log so late-registering
493
511
  // listeners (e.g. Eruda mounting after the file-sink listener) still receive entries
@@ -525,10 +543,14 @@ gg._here = (options) => {
525
543
  const { ns: nsLabel, file, line, col } = options;
526
544
  const namespace = nsLabel.startsWith('gg:') ? nsLabel : `gg:${nsLabel}`;
527
545
  const ggLogFunction = namespaceToLogFunction.get(namespace) ||
528
- namespaceToLogFunction.set(namespace, createGgDebugger(namespace)).get(namespace);
546
+ namespaceToLogFunction
547
+ .set(namespace, createGgDebugger(namespace))
548
+ .get(namespace);
529
549
  ggLogFunction(` 📝 ${namespace}`);
530
550
  const fileName = file ? file.replace(srcRootRegex, '') : nsLabel;
531
- const functionName = nsLabel.includes('@') ? nsLabel.split('@').pop() || '' : '';
551
+ const functionName = nsLabel.includes('@')
552
+ ? nsLabel.split('@').pop() || ''
553
+ : '';
532
554
  const url = file ? openInEditorUrl(file, line, col) : '';
533
555
  return { fileName, functionName, url };
534
556
  };
@@ -543,7 +565,9 @@ gg._here = (options) => {
543
565
  */
544
566
  gg._o = (ns, file, line, col, src) => ({ ns, file, line, col, src });
545
567
  gg.disable = isCloudflareWorker() ? () => '' : () => debugFactory.disable();
546
- gg.enable = isCloudflareWorker() ? () => { } : (ns) => debugFactory.enable(ns);
568
+ gg.enable = isCloudflareWorker()
569
+ ? () => { }
570
+ : (ns) => debugFactory.enable(ns);
547
571
  /**
548
572
  * Clear the persisted gg-enabled state from localStorage.
549
573
  * Useful to reset production trigger after testing with ?gg parameter.
@@ -559,6 +583,22 @@ gg.clearPersist = () => {
559
583
  }
560
584
  }
561
585
  };
586
+ /**
587
+ * Enable gg logging at runtime (e.g., after 5-tap gesture in production).
588
+ * Flips the internal enabled flag so subsequent gg() calls start capturing.
589
+ * This is session-only — does not persist to localStorage, so the next
590
+ * page load / app launch starts with gg disabled again.
591
+ *
592
+ * To enable gg across app restarts (e.g., in a prod Tauri app), run this
593
+ * in the Eruda console instead:
594
+ * localStorage.setItem("gg-enabled", "true")
595
+ * Clear with: localStorage.removeItem("gg-enabled") or gg.clearPersist()
596
+ *
597
+ * @internal Used by the Eruda gesture handler — not part of the public API.
598
+ */
599
+ export function enableGgRuntime() {
600
+ ggConfig.enabled = true;
601
+ }
562
602
  // ── Console-like methods ───────────────────────────────────────────────
563
603
  // Each public method (gg.warn, gg.error, etc.) has a corresponding internal
564
604
  // method (gg._warn, gg._error, etc.) that accepts call-site metadata from
@@ -759,7 +799,9 @@ function formatTable(data, columns) {
759
799
  allKeys = Array.from(keySet);
760
800
  rows = data.map((item, i) => ({
761
801
  '(index)': i,
762
- ...(item && typeof item === 'object' ? item : { Value: item })
802
+ ...(item && typeof item === 'object'
803
+ ? item
804
+ : { Value: item }),
763
805
  }));
764
806
  }
765
807
  }
@@ -783,7 +825,7 @@ function formatTable(data, columns) {
783
825
  '(index)': key,
784
826
  ...(val && typeof val === 'object' && !Array.isArray(val)
785
827
  ? val
786
- : { Value: val })
828
+ : { Value: val }),
787
829
  }));
788
830
  }
789
831
  // Apply column filter
@@ -821,7 +863,7 @@ function parseColor(color) {
821
863
  grey: '#808080',
822
864
  orange: '#ffa500',
823
865
  purple: '#800080',
824
- pink: '#ffc0cb'
866
+ pink: '#ffc0cb',
825
867
  };
826
868
  // Check named colors first
827
869
  const normalized = color.toLowerCase().trim();
@@ -834,7 +876,7 @@ function parseColor(color) {
834
876
  return {
835
877
  r: parseInt(hexMatch[1], 16),
836
878
  g: parseInt(hexMatch[2], 16),
837
- b: parseInt(hexMatch[3], 16)
879
+ b: parseInt(hexMatch[3], 16),
838
880
  };
839
881
  }
840
882
  // Parse short hex (#rgb)
@@ -843,7 +885,7 @@ function parseColor(color) {
843
885
  return {
844
886
  r: parseInt(shortHexMatch[1] + shortHexMatch[1], 16),
845
887
  g: parseInt(shortHexMatch[2] + shortHexMatch[2], 16),
846
- b: parseInt(shortHexMatch[3] + shortHexMatch[3], 16)
888
+ b: parseInt(shortHexMatch[3] + shortHexMatch[3], 16),
847
889
  };
848
890
  }
849
891
  // Parse rgb(r,g,b) or rgba(r,g,b,a)
@@ -852,7 +894,7 @@ function parseColor(color) {
852
894
  return {
853
895
  r: parseInt(rgbMatch[1]),
854
896
  g: parseInt(rgbMatch[2]),
855
- b: parseInt(rgbMatch[3])
897
+ b: parseInt(rgbMatch[3]),
856
898
  };
857
899
  }
858
900
  return null;
@@ -864,7 +906,7 @@ const STYLE_CODES = {
864
906
  bold: '\x1b[1m',
865
907
  dim: '\x1b[2m',
866
908
  italic: '\x1b[3m',
867
- underline: '\x1b[4m'
909
+ underline: '\x1b[4m',
868
910
  };
869
911
  /**
870
912
  * Internal helper to create chainable color function with method chaining
@@ -1006,14 +1048,14 @@ Object.defineProperty(gg, 'addLogListener', {
1006
1048
  }
1007
1049
  },
1008
1050
  writable: false,
1009
- configurable: true
1051
+ configurable: true,
1010
1052
  });
1011
1053
  Object.defineProperty(gg, 'removeLogListener', {
1012
1054
  value(callback) {
1013
1055
  _logListeners.delete(callback);
1014
1056
  },
1015
1057
  writable: false,
1016
- configurable: true
1058
+ configurable: true,
1017
1059
  });
1018
1060
  // Legacy gg._onLog — backward-compatible single-slot alias
1019
1061
  Object.defineProperty(gg, '_onLog', {
@@ -1033,7 +1075,7 @@ Object.defineProperty(gg, '_onLog', {
1033
1075
  earlyLogBuffer.forEach((entry) => callback(entry));
1034
1076
  }
1035
1077
  }
1036
- }
1078
+ },
1037
1079
  });
1038
1080
  // Namespace for adding properties to the gg function
1039
1081
  // eslint-disable-next-line @typescript-eslint/no-namespace
@@ -1057,9 +1099,11 @@ export async function runGgDiagnostics() {
1057
1099
  let ggMessage = '\n';
1058
1100
  const message = (s) => (ggMessage += `${s}\n`);
1059
1101
  const checkbox = (test) => (test ? '✅' : '❌');
1060
- const makeHint = (test, ifTrue, ifFalse = '') => (test ? ifTrue : ifFalse);
1102
+ const makeHint = (test, ifTrue, ifFalse = '') => test ? ifTrue : ifFalse;
1061
1103
  console.log(`Loaded gg module. Checking configuration...`);
1062
- const configOk = BROWSER ? ggConfig.enabled : ggConfig.enabled && ggLogTest.enabled;
1104
+ const configOk = BROWSER
1105
+ ? ggConfig.enabled
1106
+ : ggConfig.enabled && ggLogTest.enabled;
1063
1107
  if (configOk) {
1064
1108
  message(`No problems detected:`);
1065
1109
  if (BROWSER) {
@@ -1104,3 +1148,41 @@ export async function runGgDiagnostics() {
1104
1148
  if (ggConfig.showHints && !isCloudflareWorker() && !BROWSER) {
1105
1149
  runGgDiagnostics();
1106
1150
  }
1151
+ // ── File sink: browser → dev server relay ──────────────────────────────
1152
+ // Dynamically import the file-sink sender module served by ggFileSinkPlugin.
1153
+ // The sender registers a log listener that relays entries to the Vite dev
1154
+ // server via import.meta.hot.send() over the HMR WebSocket.
1155
+ //
1156
+ // Why dynamic import instead of inline code?
1157
+ // The gg module is pre-bundled by Vite's dep optimizer (esbuild), which
1158
+ // evaluates `typeof import.meta.hot` as "undefined" and tree-shakes any
1159
+ // code guarded by it. Dynamic import() is preserved by esbuild, and the
1160
+ // imported module (served at /__gg/sender.mjs by the plugin's middleware)
1161
+ // goes through Vite's normal transform pipeline where import.meta.hot IS
1162
+ // available.
1163
+ //
1164
+ // The .catch() silently handles the case where ggFileSinkPlugin isn't active
1165
+ // (e.g., production builds, or projects that don't use the file sink).
1166
+ if (BROWSER && DEV) {
1167
+ // Import the virtual module directly through Vite's module URL.
1168
+ // Dynamic string construction defeats Rollup's static import analysis —
1169
+ // without this, `vite build` fails because Rollup tries to resolve the
1170
+ // dev-server-only URL. The runtime guard (BROWSER && DEV) prevents
1171
+ // execution in production, but Rollup analyzes statically.
1172
+ const senderUrl = '/@id/' + 'virtual:gg-file-sink-sender';
1173
+ import(/* @vite-ignore */ senderUrl).catch(() => { });
1174
+ }
1175
+ // ── File sink: server-side self-registration via globalThis bridge ───────
1176
+ // When ggFileSinkPlugin is active, configureServer sets globalThis.__ggFileSink
1177
+ // with a write() function that serializes and appends entries to the JSONL file.
1178
+ // Any gg module instance (SSR, pre-bundled, monorepo-hoisted) that sees the
1179
+ // bridge registers a listener — no transform hook or path matching needed.
1180
+ if (!BROWSER &&
1181
+ typeof globalThis !== 'undefined' &&
1182
+ globalThis.__ggFileSink &&
1183
+ typeof globalThis.__ggFileSink?.write === 'function') {
1184
+ const sink = globalThis.__ggFileSink;
1185
+ gg.addLogListener((entry) => {
1186
+ sink.write(entry, 'server');
1187
+ });
1188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftium/gg",
3
- "version": "0.0.52",
3
+ "version": "0.0.54",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Leftium/gg.git"