trickle-observe 0.2.72 → 0.2.74

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.
@@ -13,6 +13,8 @@ interface LoaderOptions {
13
13
  exclude?: string[];
14
14
  debug?: boolean;
15
15
  traceVars?: boolean;
16
+ isServer?: boolean;
17
+ ingestPort?: number;
16
18
  }
17
19
  export default function trickleNextLoader(this: {
18
20
  resourcePath: string;
@@ -35,12 +35,15 @@ function trickleNextLoader(source) {
35
35
  const backendUrl = options.backendUrl ?? process.env.TRICKLE_BACKEND_URL ?? 'http://localhost:4888';
36
36
  const debug = options.debug ?? (process.env.TRICKLE_DEBUG === '1');
37
37
  const traceVars = options.traceVars ?? true;
38
+ const isServer = options.isServer ?? true;
39
+ const ingestPort = options.ingestPort ?? 4889;
38
40
  const moduleName = path_1.default.basename(resourcePath).replace(/\.[jt]sx?$/, '');
39
41
  try {
40
- // Next.js SSR renders all components on the server, so use SSR mode (node:fs)
41
- const transformed = (0, vite_plugin_1.transformEsmSource)(source, resourcePath, moduleName, backendUrl, debug, traceVars, source, true);
42
+ // Server: use node:fs for writing. Client: use fetch() to ingest server.
43
+ const ingestUrl = isServer ? null : `http://localhost:${ingestPort}/__trickle_vars`;
44
+ const transformed = (0, vite_plugin_1.transformEsmSource)(source, resourcePath, moduleName, backendUrl, debug, traceVars, source, isServer, ingestUrl);
42
45
  if (debug && transformed !== source) {
43
- console.log(`[trickle/next] Instrumented ${resourcePath}`);
46
+ console.log(`[trickle/next] Instrumented ${resourcePath} [${isServer ? 'server' : 'client'}]`);
44
47
  }
45
48
  return transformed;
46
49
  }
@@ -38,6 +38,8 @@ export interface TrickleNextOptions {
38
38
  debug?: boolean;
39
39
  /** Enable variable tracing (default: true) */
40
40
  traceVars?: boolean;
41
+ /** Port for the client-side ingest server (default: 4889) */
42
+ ingestPort?: number;
41
43
  }
42
44
  type NextConfig = Record<string, unknown> & {
43
45
  webpack?: (config: WebpackConfig, context: WebpackContext) => WebpackConfig;
@@ -58,6 +60,7 @@ interface WebpackContext {
58
60
  *
59
61
  * Adds a webpack loader that instruments all .tsx/.jsx component files at build
60
62
  * time with render tracking, useState change tracking, and hook observability.
63
+ * Also starts a tiny HTTP ingest server for client-side variable data.
61
64
  */
62
65
  export declare function withTrickle(nextConfig?: NextConfig, options?: TrickleNextOptions): NextConfig;
63
66
  export {};
@@ -34,14 +34,78 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
34
34
  Object.defineProperty(exports, "__esModule", { value: true });
35
35
  exports.withTrickle = withTrickle;
36
36
  const path_1 = __importDefault(require("path"));
37
+ const http_1 = __importDefault(require("http"));
38
+ const fs_1 = __importDefault(require("fs"));
39
+ /** Track whether the ingest server is already running (avoid starting twice) */
40
+ let ingestServerStarted = false;
41
+ /**
42
+ * Start a tiny HTTP server that receives variable data from browser clients
43
+ * and writes it to .trickle/variables.jsonl.
44
+ */
45
+ function startIngestServer(port, debug) {
46
+ if (ingestServerStarted)
47
+ return;
48
+ ingestServerStarted = true;
49
+ const dir = process.env.TRICKLE_LOCAL_DIR || path_1.default.join(process.cwd(), '.trickle');
50
+ try {
51
+ fs_1.default.mkdirSync(dir, { recursive: true });
52
+ }
53
+ catch { }
54
+ const varsFile = path_1.default.join(dir, 'variables.jsonl');
55
+ const server = http_1.default.createServer((req, res) => {
56
+ // CORS headers for browser fetch
57
+ res.setHeader('Access-Control-Allow-Origin', '*');
58
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
59
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
60
+ if (req.method === 'OPTIONS') {
61
+ res.writeHead(204);
62
+ res.end();
63
+ return;
64
+ }
65
+ if (req.method === 'POST' && req.url === '/__trickle_vars') {
66
+ let body = '';
67
+ req.on('data', (chunk) => { body += chunk; });
68
+ req.on('end', () => {
69
+ try {
70
+ if (body) {
71
+ fs_1.default.appendFileSync(varsFile, body);
72
+ }
73
+ }
74
+ catch { }
75
+ res.writeHead(200);
76
+ res.end('ok');
77
+ });
78
+ return;
79
+ }
80
+ res.writeHead(404);
81
+ res.end();
82
+ });
83
+ server.listen(port, () => {
84
+ if (debug) {
85
+ console.log(`[trickle/next] Ingest server listening on http://localhost:${port} → ${varsFile}`);
86
+ }
87
+ });
88
+ server.on('error', (err) => {
89
+ if (err.code === 'EADDRINUSE') {
90
+ // Another trickle instance already has the port — that's fine
91
+ if (debug)
92
+ console.log(`[trickle/next] Ingest port ${port} already in use (OK)`);
93
+ }
94
+ });
95
+ // Don't keep the process alive just for this server
96
+ server.unref();
97
+ }
37
98
  /**
38
99
  * Wrap your Next.js config with trickle observability.
39
100
  *
40
101
  * Adds a webpack loader that instruments all .tsx/.jsx component files at build
41
102
  * time with render tracking, useState change tracking, and hook observability.
103
+ * Also starts a tiny HTTP ingest server for client-side variable data.
42
104
  */
43
105
  function withTrickle(nextConfig = {}, options = {}) {
44
106
  const loaderPath = path_1.default.resolve(__dirname, './next-loader.js');
107
+ const ingestPort = options.ingestPort ?? 4889;
108
+ const debug = options.debug ?? (process.env.TRICKLE_DEBUG === '1');
45
109
  return {
46
110
  ...nextConfig,
47
111
  webpack(config, context) {
@@ -49,13 +113,19 @@ function withTrickle(nextConfig = {}, options = {}) {
49
113
  if (typeof nextConfig.webpack === 'function') {
50
114
  config = nextConfig.webpack(config, context);
51
115
  }
116
+ // Start the ingest server for client-side data (once)
117
+ startIngestServer(ingestPort, debug);
52
118
  config.module.rules.push({
53
119
  test: /\.(tsx?|jsx?)$/,
54
- exclude: /node_modules/,
120
+ exclude: /node_modules|trickle-observe|client-js/,
55
121
  use: [
56
122
  {
57
123
  loader: loaderPath,
58
- options,
124
+ options: {
125
+ ...options,
126
+ isServer: context.isServer,
127
+ ingestPort,
128
+ },
59
129
  },
60
130
  ],
61
131
  });
@@ -40,5 +40,7 @@ export declare function tricklePlugin(options?: TricklePluginOptions): {
40
40
  map: null;
41
41
  } | null;
42
42
  };
43
- export declare function transformEsmSource(source: string, filename: string, moduleName: string, backendUrl: string, debug: boolean, traceVars: boolean, originalSource?: string | null, isSSR?: boolean): string;
43
+ export declare function transformEsmSource(source: string, filename: string, moduleName: string, backendUrl: string, debug: boolean, traceVars: boolean, originalSource?: string | null, isSSR?: boolean,
44
+ /** URL for fetch-based browser transport (Next.js client). When set and isSSR=false, uses fetch() instead of import.meta.hot */
45
+ ingestUrl?: string | null): string;
44
46
  export default tricklePlugin;
@@ -894,7 +894,9 @@ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
894
894
  function escapeRegexStr(str) {
895
895
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
896
896
  }
897
- function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR) {
897
+ function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR,
898
+ /** URL for fetch-based browser transport (Next.js client). When set and isSSR=false, uses fetch() instead of import.meta.hot */
899
+ ingestUrl) {
898
900
  // Detect React files for component render tracking
899
901
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
900
902
  // Match top-level and nested function declarations (including async, export, export default)
@@ -1315,40 +1317,37 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1315
1317
  }
1316
1318
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1317
1319
  const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1318
- const importLines = [
1319
- `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
1320
- ];
1321
- if (needsTracing && isSSR) {
1322
- // SSR/Node.js — use file system for writing
1323
- importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
1320
+ const importLines = [];
1321
+ if (isSSR) {
1322
+ // SSR/Node.js — import trickle-observe for function wrapping + file system for writing
1323
+ importLines.push(`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`);
1324
+ if (needsTracing) {
1325
+ importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
1326
+ }
1327
+ }
1328
+ // Browser mode: no imports needed — variable tracers are self-contained,
1329
+ // function wrapping is a no-op, and transport uses import.meta.hot
1330
+ const prefixLines = [...importLines];
1331
+ if (isSSR) {
1332
+ prefixLines.push(`__trickle_configure({ backendUrl: ${JSON.stringify(backendUrl)}, batchIntervalMs: 2000, debug: ${debug}, enabled: true, environment: 'node' });`, `function __trickle_wrap(fn, name, paramNames) {`, ` const opts = {`, ` functionName: name,`, ` module: ${JSON.stringify(moduleName)},`, ` trackArgs: true,`, ` trackReturn: true,`, ` sampleRate: 1,`, ` maxDepth: 5,`, ` environment: 'node',`, ` enabled: true,`, ` };`, ` if (paramNames && paramNames.length) opts.paramNames = paramNames;`, ` return __trickle_wrapFn(fn, opts);`, `}`);
1333
+ }
1334
+ else {
1335
+ // Browser mode: __trickle_wrap is a no-op (function wrapping uses Node.js APIs)
1336
+ prefixLines.push(`function __trickle_wrap(fn) { return fn; }`);
1324
1337
  }
1325
- const prefixLines = [
1326
- ...importLines,
1327
- `__trickle_configure({ backendUrl: ${JSON.stringify(backendUrl)}, batchIntervalMs: 2000, debug: ${debug}, enabled: true, environment: 'node' });`,
1328
- `function __trickle_wrap(fn, name, paramNames) {`,
1329
- ` const opts = {`,
1330
- ` functionName: name,`,
1331
- ` module: ${JSON.stringify(moduleName)},`,
1332
- ` trackArgs: true,`,
1333
- ` trackReturn: true,`,
1334
- ` sampleRate: 1,`,
1335
- ` maxDepth: 5,`,
1336
- ` environment: 'node',`,
1337
- ` enabled: true,`,
1338
- ` };`,
1339
- ` if (paramNames && paramNames.length) opts.paramNames = paramNames;`,
1340
- ` return __trickle_wrapFn(fn, opts);`,
1341
- `}`,
1342
- ];
1343
1338
  // Add unified __trickle_send() transport — browser uses HMR WebSocket, SSR uses fs
1344
1339
  if (needsTracing) {
1345
1340
  if (isSSR) {
1346
1341
  // SSR/Node.js mode — write directly to file system
1347
1342
  prefixLines.push(`let __trickle_varsFile = null;`, `function __trickle_send(line) {`, ` try {`, ` if (!__trickle_varsFile) {`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` __trickle_varsFile = __trickle_join(dir, 'variables.jsonl');`, ` }`, ` __trickle_appendFileSync(__trickle_varsFile, line + '\\n');`, ` } catch(e) {}`, `}`);
1348
1343
  }
1344
+ else if (ingestUrl) {
1345
+ // Browser mode with fetch transport (Next.js client)
1346
+ prefixLines.push(`const __trickle_sendBuf = [];`, `let __trickle_sendTimer = null;`, `function __trickle_flush() {`, ` if (__trickle_sendBuf.length === 0) return;`, ` const lines = __trickle_sendBuf.join('\\n') + '\\n';`, ` __trickle_sendBuf.length = 0;`, ` try { fetch(${JSON.stringify(ingestUrl)}, { method: 'POST', body: lines, headers: { 'Content-Type': 'text/plain' } }).catch(function(){}); } catch(e) {}`, `}`, `function __trickle_send(line) {`, ` __trickle_sendBuf.push(line);`, ` if (!__trickle_sendTimer) {`, ` __trickle_sendTimer = setTimeout(function() { __trickle_sendTimer = null; __trickle_flush(); }, 300);`, ` }`, `}`);
1347
+ }
1349
1348
  else {
1350
1349
  // Browser mode — buffer and send via Vite HMR WebSocket
1351
- prefixLines.push(`const __trickle_sendBuf = [];`, `let __trickle_sendTimer = null;`, `function __trickle_flush() {`, ` if (__trickle_sendBuf.length === 0) return;`, ` const lines = __trickle_sendBuf.join('\\n') + '\\n';`, ` __trickle_sendBuf.length = 0;`, ` try { if (import.meta.hot) import.meta.hot.send('trickle:vars', { lines }); } catch(e) {}`, `}`, `function __trickle_send(line) {`, ` __trickle_sendBuf.push(line);`, ` if (!__trickle_sendTimer) {`, ` __trickle_sendTimer = setTimeout(() => { __trickle_sendTimer = null; __trickle_flush(); }, 300);`, ` }`, `}`);
1350
+ prefixLines.push(`const __trickle_sendBuf = [];`, `let __trickle_sendTimer = null;`, `function __trickle_flush() {`, ` if (__trickle_sendBuf.length === 0) return;`, ` const lines = __trickle_sendBuf.join('\\n') + '\\n';`, ` __trickle_sendBuf.length = 0;`, ` try { if (import.meta.hot) import.meta.hot.send('trickle:vars', { lines }); } catch(e) {}`, `}`, `function __trickle_send(line) {`, ` __trickle_sendBuf.push(line);`, ` if (!__trickle_sendTimer) {`, ` __trickle_sendTimer = setTimeout(function() { __trickle_sendTimer = null; __trickle_flush(); }, 300);`, ` }`, `}`);
1352
1351
  }
1353
1352
  }
1354
1353
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
@@ -1460,6 +1459,14 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1460
1459
  for (const { position, code } of allInsertions) {
1461
1460
  result = result.slice(0, position) + code + result.slice(position);
1462
1461
  }
1463
- return prefix + result;
1462
+ // Preserve 'use client' / 'use server' directives — they must be the first expression
1463
+ // in the file (before any imports or code). Extract them from result and prepend before prefix.
1464
+ let directive = '';
1465
+ const directiveMatch = result.match(/^(\s*(?:'use client'|"use client"|'use server'|"use server")\s*;?\s*\n?)/);
1466
+ if (directiveMatch) {
1467
+ directive = directiveMatch[1];
1468
+ result = result.slice(directiveMatch[0].length);
1469
+ }
1470
+ return directive + prefix + result;
1464
1471
  }
1465
1472
  exports.default = tricklePlugin;
@@ -887,7 +887,9 @@ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
887
887
  function escapeRegexStr(str) {
888
888
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
889
889
  }
890
- export function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR) {
890
+ export function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR,
891
+ /** URL for fetch-based browser transport (Next.js client). When set and isSSR=false, uses fetch() instead of import.meta.hot */
892
+ ingestUrl) {
891
893
  // Detect React files for component render tracking
892
894
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
893
895
  // Match top-level and nested function declarations (including async, export, export default)
@@ -1308,40 +1310,37 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1308
1310
  }
1309
1311
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1310
1312
  const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1311
- const importLines = [
1312
- `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
1313
- ];
1314
- if (needsTracing && isSSR) {
1315
- // SSR/Node.js — use file system for writing
1316
- importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
1313
+ const importLines = [];
1314
+ if (isSSR) {
1315
+ // SSR/Node.js — import trickle-observe for function wrapping + file system for writing
1316
+ importLines.push(`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`);
1317
+ if (needsTracing) {
1318
+ importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
1319
+ }
1320
+ }
1321
+ // Browser mode: no imports needed — variable tracers are self-contained,
1322
+ // function wrapping is a no-op, and transport uses import.meta.hot
1323
+ const prefixLines = [...importLines];
1324
+ if (isSSR) {
1325
+ prefixLines.push(`__trickle_configure({ backendUrl: ${JSON.stringify(backendUrl)}, batchIntervalMs: 2000, debug: ${debug}, enabled: true, environment: 'node' });`, `function __trickle_wrap(fn, name, paramNames) {`, ` const opts = {`, ` functionName: name,`, ` module: ${JSON.stringify(moduleName)},`, ` trackArgs: true,`, ` trackReturn: true,`, ` sampleRate: 1,`, ` maxDepth: 5,`, ` environment: 'node',`, ` enabled: true,`, ` };`, ` if (paramNames && paramNames.length) opts.paramNames = paramNames;`, ` return __trickle_wrapFn(fn, opts);`, `}`);
1326
+ }
1327
+ else {
1328
+ // Browser mode: __trickle_wrap is a no-op (function wrapping uses Node.js APIs)
1329
+ prefixLines.push(`function __trickle_wrap(fn) { return fn; }`);
1317
1330
  }
1318
- const prefixLines = [
1319
- ...importLines,
1320
- `__trickle_configure({ backendUrl: ${JSON.stringify(backendUrl)}, batchIntervalMs: 2000, debug: ${debug}, enabled: true, environment: 'node' });`,
1321
- `function __trickle_wrap(fn, name, paramNames) {`,
1322
- ` const opts = {`,
1323
- ` functionName: name,`,
1324
- ` module: ${JSON.stringify(moduleName)},`,
1325
- ` trackArgs: true,`,
1326
- ` trackReturn: true,`,
1327
- ` sampleRate: 1,`,
1328
- ` maxDepth: 5,`,
1329
- ` environment: 'node',`,
1330
- ` enabled: true,`,
1331
- ` };`,
1332
- ` if (paramNames && paramNames.length) opts.paramNames = paramNames;`,
1333
- ` return __trickle_wrapFn(fn, opts);`,
1334
- `}`,
1335
- ];
1336
1331
  // Add unified __trickle_send() transport — browser uses HMR WebSocket, SSR uses fs
1337
1332
  if (needsTracing) {
1338
1333
  if (isSSR) {
1339
1334
  // SSR/Node.js mode — write directly to file system
1340
1335
  prefixLines.push(`let __trickle_varsFile = null;`, `function __trickle_send(line) {`, ` try {`, ` if (!__trickle_varsFile) {`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` __trickle_varsFile = __trickle_join(dir, 'variables.jsonl');`, ` }`, ` __trickle_appendFileSync(__trickle_varsFile, line + '\\n');`, ` } catch(e) {}`, `}`);
1341
1336
  }
1337
+ else if (ingestUrl) {
1338
+ // Browser mode with fetch transport (Next.js client)
1339
+ prefixLines.push(`const __trickle_sendBuf = [];`, `let __trickle_sendTimer = null;`, `function __trickle_flush() {`, ` if (__trickle_sendBuf.length === 0) return;`, ` const lines = __trickle_sendBuf.join('\\n') + '\\n';`, ` __trickle_sendBuf.length = 0;`, ` try { fetch(${JSON.stringify(ingestUrl)}, { method: 'POST', body: lines, headers: { 'Content-Type': 'text/plain' } }).catch(function(){}); } catch(e) {}`, `}`, `function __trickle_send(line) {`, ` __trickle_sendBuf.push(line);`, ` if (!__trickle_sendTimer) {`, ` __trickle_sendTimer = setTimeout(function() { __trickle_sendTimer = null; __trickle_flush(); }, 300);`, ` }`, `}`);
1340
+ }
1342
1341
  else {
1343
1342
  // Browser mode — buffer and send via Vite HMR WebSocket
1344
- prefixLines.push(`const __trickle_sendBuf = [];`, `let __trickle_sendTimer = null;`, `function __trickle_flush() {`, ` if (__trickle_sendBuf.length === 0) return;`, ` const lines = __trickle_sendBuf.join('\\n') + '\\n';`, ` __trickle_sendBuf.length = 0;`, ` try { if (import.meta.hot) import.meta.hot.send('trickle:vars', { lines }); } catch(e) {}`, `}`, `function __trickle_send(line) {`, ` __trickle_sendBuf.push(line);`, ` if (!__trickle_sendTimer) {`, ` __trickle_sendTimer = setTimeout(() => { __trickle_sendTimer = null; __trickle_flush(); }, 300);`, ` }`, `}`);
1343
+ prefixLines.push(`const __trickle_sendBuf = [];`, `let __trickle_sendTimer = null;`, `function __trickle_flush() {`, ` if (__trickle_sendBuf.length === 0) return;`, ` const lines = __trickle_sendBuf.join('\\n') + '\\n';`, ` __trickle_sendBuf.length = 0;`, ` try { if (import.meta.hot) import.meta.hot.send('trickle:vars', { lines }); } catch(e) {}`, `}`, `function __trickle_send(line) {`, ` __trickle_sendBuf.push(line);`, ` if (!__trickle_sendTimer) {`, ` __trickle_sendTimer = setTimeout(function() { __trickle_sendTimer = null; __trickle_flush(); }, 300);`, ` }`, `}`);
1345
1344
  }
1346
1345
  }
1347
1346
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
@@ -1453,6 +1452,14 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1453
1452
  for (const { position, code } of allInsertions) {
1454
1453
  result = result.slice(0, position) + code + result.slice(position);
1455
1454
  }
1456
- return prefix + result;
1455
+ // Preserve 'use client' / 'use server' directives — they must be the first expression
1456
+ // in the file (before any imports or code). Extract them from result and prepend before prefix.
1457
+ let directive = '';
1458
+ const directiveMatch = result.match(/^(\s*(?:'use client'|"use client"|'use server'|"use server")\s*;?\s*\n?)/);
1459
+ if (directiveMatch) {
1460
+ directive = directiveMatch[1];
1461
+ result = result.slice(directiveMatch[0].length);
1462
+ }
1463
+ return directive + prefix + result;
1457
1464
  }
1458
1465
  export default tricklePlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.72",
3
+ "version": "0.2.74",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -17,6 +17,8 @@ interface LoaderOptions {
17
17
  exclude?: string[];
18
18
  debug?: boolean;
19
19
  traceVars?: boolean;
20
+ isServer?: boolean;
21
+ ingestPort?: number;
20
22
  }
21
23
 
22
24
  // webpack loader — `this` is the LoaderContext (must not be an arrow function)
@@ -40,13 +42,16 @@ export default function trickleNextLoader(this: { resourcePath: string; getOptio
40
42
  const backendUrl = options.backendUrl ?? process.env.TRICKLE_BACKEND_URL ?? 'http://localhost:4888';
41
43
  const debug = options.debug ?? (process.env.TRICKLE_DEBUG === '1');
42
44
  const traceVars = options.traceVars ?? true;
45
+ const isServer = options.isServer ?? true;
46
+ const ingestPort = options.ingestPort ?? 4889;
43
47
  const moduleName = path.basename(resourcePath).replace(/\.[jt]sx?$/, '');
44
48
 
45
49
  try {
46
- // Next.js SSR renders all components on the server, so use SSR mode (node:fs)
47
- const transformed = transformEsmSource(source, resourcePath, moduleName, backendUrl, debug, traceVars, source, true);
50
+ // Server: use node:fs for writing. Client: use fetch() to ingest server.
51
+ const ingestUrl = isServer ? null : `http://localhost:${ingestPort}/__trickle_vars`;
52
+ const transformed = transformEsmSource(source, resourcePath, moduleName, backendUrl, debug, traceVars, source, isServer, ingestUrl);
48
53
  if (debug && transformed !== source) {
49
- console.log(`[trickle/next] Instrumented ${resourcePath}`);
54
+ console.log(`[trickle/next] Instrumented ${resourcePath} [${isServer ? 'server' : 'client'}]`);
50
55
  }
51
56
  return transformed;
52
57
  } catch {
@@ -29,6 +29,8 @@
29
29
  */
30
30
 
31
31
  import path from 'path';
32
+ import http from 'http';
33
+ import fs from 'fs';
32
34
 
33
35
  export interface TrickleNextOptions {
34
36
  /** Backend URL (default: http://localhost:4888 or TRICKLE_BACKEND_URL env var) */
@@ -41,6 +43,8 @@ export interface TrickleNextOptions {
41
43
  debug?: boolean;
42
44
  /** Enable variable tracing (default: true) */
43
45
  traceVars?: boolean;
46
+ /** Port for the client-side ingest server (default: 4889) */
47
+ ingestPort?: number;
44
48
  }
45
49
 
46
50
  type NextConfig = Record<string, unknown> & {
@@ -58,14 +62,80 @@ interface WebpackContext {
58
62
  [key: string]: unknown;
59
63
  }
60
64
 
65
+ /** Track whether the ingest server is already running (avoid starting twice) */
66
+ let ingestServerStarted = false;
67
+
68
+ /**
69
+ * Start a tiny HTTP server that receives variable data from browser clients
70
+ * and writes it to .trickle/variables.jsonl.
71
+ */
72
+ function startIngestServer(port: number, debug: boolean): void {
73
+ if (ingestServerStarted) return;
74
+ ingestServerStarted = true;
75
+
76
+ const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
77
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
78
+ const varsFile = path.join(dir, 'variables.jsonl');
79
+
80
+ const server = http.createServer((req, res) => {
81
+ // CORS headers for browser fetch
82
+ res.setHeader('Access-Control-Allow-Origin', '*');
83
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
84
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
85
+
86
+ if (req.method === 'OPTIONS') {
87
+ res.writeHead(204);
88
+ res.end();
89
+ return;
90
+ }
91
+
92
+ if (req.method === 'POST' && req.url === '/__trickle_vars') {
93
+ let body = '';
94
+ req.on('data', (chunk: string) => { body += chunk; });
95
+ req.on('end', () => {
96
+ try {
97
+ if (body) {
98
+ fs.appendFileSync(varsFile, body);
99
+ }
100
+ } catch {}
101
+ res.writeHead(200);
102
+ res.end('ok');
103
+ });
104
+ return;
105
+ }
106
+
107
+ res.writeHead(404);
108
+ res.end();
109
+ });
110
+
111
+ server.listen(port, () => {
112
+ if (debug) {
113
+ console.log(`[trickle/next] Ingest server listening on http://localhost:${port} → ${varsFile}`);
114
+ }
115
+ });
116
+
117
+ server.on('error', (err: NodeJS.ErrnoException) => {
118
+ if (err.code === 'EADDRINUSE') {
119
+ // Another trickle instance already has the port — that's fine
120
+ if (debug) console.log(`[trickle/next] Ingest port ${port} already in use (OK)`);
121
+ }
122
+ });
123
+
124
+ // Don't keep the process alive just for this server
125
+ server.unref();
126
+ }
127
+
61
128
  /**
62
129
  * Wrap your Next.js config with trickle observability.
63
130
  *
64
131
  * Adds a webpack loader that instruments all .tsx/.jsx component files at build
65
132
  * time with render tracking, useState change tracking, and hook observability.
133
+ * Also starts a tiny HTTP ingest server for client-side variable data.
66
134
  */
67
135
  export function withTrickle(nextConfig: NextConfig = {}, options: TrickleNextOptions = {}): NextConfig {
68
136
  const loaderPath = path.resolve(__dirname, './next-loader.js');
137
+ const ingestPort = options.ingestPort ?? 4889;
138
+ const debug = options.debug ?? (process.env.TRICKLE_DEBUG === '1');
69
139
 
70
140
  return {
71
141
  ...nextConfig,
@@ -75,13 +145,20 @@ export function withTrickle(nextConfig: NextConfig = {}, options: TrickleNextOpt
75
145
  config = nextConfig.webpack(config, context);
76
146
  }
77
147
 
148
+ // Start the ingest server for client-side data (once)
149
+ startIngestServer(ingestPort, debug);
150
+
78
151
  config.module.rules.push({
79
152
  test: /\.(tsx?|jsx?)$/,
80
- exclude: /node_modules/,
153
+ exclude: /node_modules|trickle-observe|client-js/,
81
154
  use: [
82
155
  {
83
156
  loader: loaderPath,
84
- options,
157
+ options: {
158
+ ...options,
159
+ isServer: context.isServer,
160
+ ingestPort,
161
+ },
85
162
  },
86
163
  ],
87
164
  });
@@ -889,6 +889,8 @@ export function transformEsmSource(
889
889
  traceVars: boolean,
890
890
  originalSource?: string | null,
891
891
  isSSR?: boolean,
892
+ /** URL for fetch-based browser transport (Next.js client). When set and isSSR=false, uses fetch() instead of import.meta.hot */
893
+ ingestUrl?: string | null,
892
894
  ): string {
893
895
  // Detect React files for component render tracking
894
896
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
@@ -1309,35 +1311,49 @@ export function transformEsmSource(
1309
1311
 
1310
1312
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1311
1313
  const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1312
- const importLines: string[] = [
1313
- `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
1314
- ];
1315
- if (needsTracing && isSSR) {
1316
- // SSR/Node.js — use file system for writing
1314
+ const importLines: string[] = [];
1315
+
1316
+ if (isSSR) {
1317
+ // SSR/Node.js import trickle-observe for function wrapping + file system for writing
1317
1318
  importLines.push(
1318
- `import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
1319
- `import { join as __trickle_join } from 'node:path';`,
1319
+ `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
1320
1320
  );
1321
+ if (needsTracing) {
1322
+ importLines.push(
1323
+ `import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
1324
+ `import { join as __trickle_join } from 'node:path';`,
1325
+ );
1326
+ }
1321
1327
  }
1328
+ // Browser mode: no imports needed — variable tracers are self-contained,
1329
+ // function wrapping is a no-op, and transport uses import.meta.hot
1330
+
1331
+ const prefixLines = [...importLines];
1322
1332
 
1323
- const prefixLines = [
1324
- ...importLines,
1325
- `__trickle_configure({ backendUrl: ${JSON.stringify(backendUrl)}, batchIntervalMs: 2000, debug: ${debug}, enabled: true, environment: 'node' });`,
1326
- `function __trickle_wrap(fn, name, paramNames) {`,
1327
- ` const opts = {`,
1328
- ` functionName: name,`,
1329
- ` module: ${JSON.stringify(moduleName)},`,
1330
- ` trackArgs: true,`,
1331
- ` trackReturn: true,`,
1332
- ` sampleRate: 1,`,
1333
- ` maxDepth: 5,`,
1334
- ` environment: 'node',`,
1335
- ` enabled: true,`,
1336
- ` };`,
1337
- ` if (paramNames && paramNames.length) opts.paramNames = paramNames;`,
1338
- ` return __trickle_wrapFn(fn, opts);`,
1339
- `}`,
1340
- ];
1333
+ if (isSSR) {
1334
+ prefixLines.push(
1335
+ `__trickle_configure({ backendUrl: ${JSON.stringify(backendUrl)}, batchIntervalMs: 2000, debug: ${debug}, enabled: true, environment: 'node' });`,
1336
+ `function __trickle_wrap(fn, name, paramNames) {`,
1337
+ ` const opts = {`,
1338
+ ` functionName: name,`,
1339
+ ` module: ${JSON.stringify(moduleName)},`,
1340
+ ` trackArgs: true,`,
1341
+ ` trackReturn: true,`,
1342
+ ` sampleRate: 1,`,
1343
+ ` maxDepth: 5,`,
1344
+ ` environment: 'node',`,
1345
+ ` enabled: true,`,
1346
+ ` };`,
1347
+ ` if (paramNames && paramNames.length) opts.paramNames = paramNames;`,
1348
+ ` return __trickle_wrapFn(fn, opts);`,
1349
+ `}`,
1350
+ );
1351
+ } else {
1352
+ // Browser mode: __trickle_wrap is a no-op (function wrapping uses Node.js APIs)
1353
+ prefixLines.push(
1354
+ `function __trickle_wrap(fn) { return fn; }`,
1355
+ );
1356
+ }
1341
1357
 
1342
1358
  // Add unified __trickle_send() transport — browser uses HMR WebSocket, SSR uses fs
1343
1359
  if (needsTracing) {
@@ -1356,6 +1372,24 @@ export function transformEsmSource(
1356
1372
  ` } catch(e) {}`,
1357
1373
  `}`,
1358
1374
  );
1375
+ } else if (ingestUrl) {
1376
+ // Browser mode with fetch transport (Next.js client)
1377
+ prefixLines.push(
1378
+ `const __trickle_sendBuf = [];`,
1379
+ `let __trickle_sendTimer = null;`,
1380
+ `function __trickle_flush() {`,
1381
+ ` if (__trickle_sendBuf.length === 0) return;`,
1382
+ ` const lines = __trickle_sendBuf.join('\\n') + '\\n';`,
1383
+ ` __trickle_sendBuf.length = 0;`,
1384
+ ` try { fetch(${JSON.stringify(ingestUrl)}, { method: 'POST', body: lines, headers: { 'Content-Type': 'text/plain' } }).catch(function(){}); } catch(e) {}`,
1385
+ `}`,
1386
+ `function __trickle_send(line) {`,
1387
+ ` __trickle_sendBuf.push(line);`,
1388
+ ` if (!__trickle_sendTimer) {`,
1389
+ ` __trickle_sendTimer = setTimeout(function() { __trickle_sendTimer = null; __trickle_flush(); }, 300);`,
1390
+ ` }`,
1391
+ `}`,
1392
+ );
1359
1393
  } else {
1360
1394
  // Browser mode — buffer and send via Vite HMR WebSocket
1361
1395
  prefixLines.push(
@@ -1370,7 +1404,7 @@ export function transformEsmSource(
1370
1404
  `function __trickle_send(line) {`,
1371
1405
  ` __trickle_sendBuf.push(line);`,
1372
1406
  ` if (!__trickle_sendTimer) {`,
1373
- ` __trickle_sendTimer = setTimeout(() => { __trickle_sendTimer = null; __trickle_flush(); }, 300);`,
1407
+ ` __trickle_sendTimer = setTimeout(function() { __trickle_sendTimer = null; __trickle_flush(); }, 300);`,
1374
1408
  ` }`,
1375
1409
  `}`,
1376
1410
  );
@@ -1629,7 +1663,16 @@ export function transformEsmSource(
1629
1663
  result = result.slice(0, position) + code + result.slice(position);
1630
1664
  }
1631
1665
 
1632
- return prefix + result;
1666
+ // Preserve 'use client' / 'use server' directives — they must be the first expression
1667
+ // in the file (before any imports or code). Extract them from result and prepend before prefix.
1668
+ let directive = '';
1669
+ const directiveMatch = result.match(/^(\s*(?:'use client'|"use client"|'use server'|"use server")\s*;?\s*\n?)/);
1670
+ if (directiveMatch) {
1671
+ directive = directiveMatch[1];
1672
+ result = result.slice(directiveMatch[0].length);
1673
+ }
1674
+
1675
+ return directive + prefix + result;
1633
1676
  }
1634
1677
 
1635
1678
  export default tricklePlugin;