trickle-observe 0.2.73 → 0.2.75

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;
@@ -261,6 +261,14 @@ function findVarDeclarations(source) {
261
261
  // Skip TS compiled vars
262
262
  if (varName === '_a' || varName === '_b' || varName === '_c')
263
263
  continue;
264
+ // Skip React Refresh / HMR internals (Vite, webpack, Next.js inject these)
265
+ if (varName === 'prevRefreshReg' || varName === 'prevRefreshSig' || varName === 'inWebWorker' || varName === 'invalidateMessage')
266
+ continue;
267
+ if (varName === '_s' || varName === '_c2' || varName === '_s2')
268
+ continue;
269
+ // Skip single-underscore discard variables
270
+ if (varName === '_')
271
+ continue;
264
272
  // Check if this is a require() call or import — skip those
265
273
  const restOfLine = source.slice(vmatch.index + vmatch[0].length - 1, vmatch.index + vmatch[0].length + 200);
266
274
  if (/^\s*require\s*\(/.test(restOfLine))
@@ -505,6 +513,11 @@ function findReassignments(source) {
505
513
  // Skip 'this', 'self', 'super' (not reassignable in practice)
506
514
  if (varName === 'this' || varName === 'super')
507
515
  continue;
516
+ // Skip React Refresh / HMR internals and discard variables
517
+ if (varName === 'prevRefreshReg' || varName === 'prevRefreshSig' || varName === 'inWebWorker')
518
+ continue;
519
+ if (varName === '_s' || varName === '_c2' || varName === '_s2' || varName === '_')
520
+ continue;
508
521
  // Skip keywords that could look like identifiers
509
522
  if (['if', 'else', 'while', 'for', 'do', 'switch', 'case', 'default', 'return', 'throw',
510
523
  'break', 'continue', 'try', 'catch', 'finally', 'new', 'delete', 'typeof', 'void',
@@ -894,7 +907,9 @@ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
894
907
  function escapeRegexStr(str) {
895
908
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
896
909
  }
897
- function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR) {
910
+ function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR,
911
+ /** URL for fetch-based browser transport (Next.js client). When set and isSSR=false, uses fetch() instead of import.meta.hot */
912
+ ingestUrl) {
898
913
  // Detect React files for component render tracking
899
914
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
900
915
  // Match top-level and nested function declarations (including async, export, export default)
@@ -1339,9 +1354,13 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1339
1354
  // SSR/Node.js mode — write directly to file system
1340
1355
  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
1356
  }
1357
+ else if (ingestUrl) {
1358
+ // Browser mode with fetch transport (Next.js client)
1359
+ 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);`, ` }`, `}`);
1360
+ }
1342
1361
  else {
1343
1362
  // 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);`, ` }`, `}`);
1363
+ 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
1364
  }
1346
1365
  }
1347
1366
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
@@ -1453,6 +1472,14 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1453
1472
  for (const { position, code } of allInsertions) {
1454
1473
  result = result.slice(0, position) + code + result.slice(position);
1455
1474
  }
1456
- return prefix + result;
1475
+ // Preserve 'use client' / 'use server' directives — they must be the first expression
1476
+ // in the file (before any imports or code). Extract them from result and prepend before prefix.
1477
+ let directive = '';
1478
+ const directiveMatch = result.match(/^(\s*(?:'use client'|"use client"|'use server'|"use server")\s*;?\s*\n?)/);
1479
+ if (directiveMatch) {
1480
+ directive = directiveMatch[1];
1481
+ result = result.slice(directiveMatch[0].length);
1482
+ }
1483
+ return directive + prefix + result;
1457
1484
  }
1458
1485
  exports.default = tricklePlugin;
@@ -254,6 +254,14 @@ function findVarDeclarations(source) {
254
254
  // Skip TS compiled vars
255
255
  if (varName === '_a' || varName === '_b' || varName === '_c')
256
256
  continue;
257
+ // Skip React Refresh / HMR internals (Vite, webpack, Next.js inject these)
258
+ if (varName === 'prevRefreshReg' || varName === 'prevRefreshSig' || varName === 'inWebWorker' || varName === 'invalidateMessage')
259
+ continue;
260
+ if (varName === '_s' || varName === '_c2' || varName === '_s2')
261
+ continue;
262
+ // Skip single-underscore discard variables
263
+ if (varName === '_')
264
+ continue;
257
265
  // Check if this is a require() call or import — skip those
258
266
  const restOfLine = source.slice(vmatch.index + vmatch[0].length - 1, vmatch.index + vmatch[0].length + 200);
259
267
  if (/^\s*require\s*\(/.test(restOfLine))
@@ -498,6 +506,11 @@ function findReassignments(source) {
498
506
  // Skip 'this', 'self', 'super' (not reassignable in practice)
499
507
  if (varName === 'this' || varName === 'super')
500
508
  continue;
509
+ // Skip React Refresh / HMR internals and discard variables
510
+ if (varName === 'prevRefreshReg' || varName === 'prevRefreshSig' || varName === 'inWebWorker')
511
+ continue;
512
+ if (varName === '_s' || varName === '_c2' || varName === '_s2' || varName === '_')
513
+ continue;
501
514
  // Skip keywords that could look like identifiers
502
515
  if (['if', 'else', 'while', 'for', 'do', 'switch', 'case', 'default', 'return', 'throw',
503
516
  'break', 'continue', 'try', 'catch', 'finally', 'new', 'delete', 'typeof', 'void',
@@ -887,7 +900,9 @@ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
887
900
  function escapeRegexStr(str) {
888
901
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
889
902
  }
890
- export function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR) {
903
+ export function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR,
904
+ /** URL for fetch-based browser transport (Next.js client). When set and isSSR=false, uses fetch() instead of import.meta.hot */
905
+ ingestUrl) {
891
906
  // Detect React files for component render tracking
892
907
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
893
908
  // Match top-level and nested function declarations (including async, export, export default)
@@ -1332,9 +1347,13 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1332
1347
  // SSR/Node.js mode — write directly to file system
1333
1348
  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) {}`, `}`);
1334
1349
  }
1350
+ else if (ingestUrl) {
1351
+ // Browser mode with fetch transport (Next.js client)
1352
+ 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);`, ` }`, `}`);
1353
+ }
1335
1354
  else {
1336
1355
  // Browser mode — buffer and send via Vite HMR WebSocket
1337
- 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);`, ` }`, `}`);
1356
+ 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);`, ` }`, `}`);
1338
1357
  }
1339
1358
  }
1340
1359
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
@@ -1446,6 +1465,14 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1446
1465
  for (const { position, code } of allInsertions) {
1447
1466
  result = result.slice(0, position) + code + result.slice(position);
1448
1467
  }
1449
- return prefix + result;
1468
+ // Preserve 'use client' / 'use server' directives — they must be the first expression
1469
+ // in the file (before any imports or code). Extract them from result and prepend before prefix.
1470
+ let directive = '';
1471
+ const directiveMatch = result.match(/^(\s*(?:'use client'|"use client"|'use server'|"use server")\s*;?\s*\n?)/);
1472
+ if (directiveMatch) {
1473
+ directive = directiveMatch[1];
1474
+ result = result.slice(directiveMatch[0].length);
1475
+ }
1476
+ return directive + prefix + result;
1450
1477
  }
1451
1478
  export default tricklePlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.73",
3
+ "version": "0.2.75",
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
  });
@@ -257,6 +257,11 @@ function findVarDeclarations(source: string): Array<{ lineEnd: number; varName:
257
257
  if (varName.startsWith('__trickle')) continue;
258
258
  // Skip TS compiled vars
259
259
  if (varName === '_a' || varName === '_b' || varName === '_c') continue;
260
+ // Skip React Refresh / HMR internals (Vite, webpack, Next.js inject these)
261
+ if (varName === 'prevRefreshReg' || varName === 'prevRefreshSig' || varName === 'inWebWorker' || varName === 'invalidateMessage') continue;
262
+ if (varName === '_s' || varName === '_c2' || varName === '_s2') continue;
263
+ // Skip single-underscore discard variables
264
+ if (varName === '_') continue;
260
265
 
261
266
  // Check if this is a require() call or import — skip those
262
267
  const restOfLine = source.slice(vmatch.index + vmatch[0].length - 1, vmatch.index + vmatch[0].length + 200);
@@ -486,6 +491,9 @@ function findReassignments(source: string): Array<{ lineEnd: number; varName: st
486
491
  if (varName === '_a' || varName === '_b' || varName === '_c') continue;
487
492
  // Skip 'this', 'self', 'super' (not reassignable in practice)
488
493
  if (varName === 'this' || varName === 'super') continue;
494
+ // Skip React Refresh / HMR internals and discard variables
495
+ if (varName === 'prevRefreshReg' || varName === 'prevRefreshSig' || varName === 'inWebWorker') continue;
496
+ if (varName === '_s' || varName === '_c2' || varName === '_s2' || varName === '_') continue;
489
497
  // Skip keywords that could look like identifiers
490
498
  if (['if', 'else', 'while', 'for', 'do', 'switch', 'case', 'default', 'return', 'throw',
491
499
  'break', 'continue', 'try', 'catch', 'finally', 'new', 'delete', 'typeof', 'void',
@@ -889,6 +897,8 @@ export function transformEsmSource(
889
897
  traceVars: boolean,
890
898
  originalSource?: string | null,
891
899
  isSSR?: boolean,
900
+ /** URL for fetch-based browser transport (Next.js client). When set and isSSR=false, uses fetch() instead of import.meta.hot */
901
+ ingestUrl?: string | null,
892
902
  ): string {
893
903
  // Detect React files for component render tracking
894
904
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
@@ -1370,6 +1380,24 @@ export function transformEsmSource(
1370
1380
  ` } catch(e) {}`,
1371
1381
  `}`,
1372
1382
  );
1383
+ } else if (ingestUrl) {
1384
+ // Browser mode with fetch transport (Next.js client)
1385
+ prefixLines.push(
1386
+ `const __trickle_sendBuf = [];`,
1387
+ `let __trickle_sendTimer = null;`,
1388
+ `function __trickle_flush() {`,
1389
+ ` if (__trickle_sendBuf.length === 0) return;`,
1390
+ ` const lines = __trickle_sendBuf.join('\\n') + '\\n';`,
1391
+ ` __trickle_sendBuf.length = 0;`,
1392
+ ` try { fetch(${JSON.stringify(ingestUrl)}, { method: 'POST', body: lines, headers: { 'Content-Type': 'text/plain' } }).catch(function(){}); } catch(e) {}`,
1393
+ `}`,
1394
+ `function __trickle_send(line) {`,
1395
+ ` __trickle_sendBuf.push(line);`,
1396
+ ` if (!__trickle_sendTimer) {`,
1397
+ ` __trickle_sendTimer = setTimeout(function() { __trickle_sendTimer = null; __trickle_flush(); }, 300);`,
1398
+ ` }`,
1399
+ `}`,
1400
+ );
1373
1401
  } else {
1374
1402
  // Browser mode — buffer and send via Vite HMR WebSocket
1375
1403
  prefixLines.push(
@@ -1384,7 +1412,7 @@ export function transformEsmSource(
1384
1412
  `function __trickle_send(line) {`,
1385
1413
  ` __trickle_sendBuf.push(line);`,
1386
1414
  ` if (!__trickle_sendTimer) {`,
1387
- ` __trickle_sendTimer = setTimeout(() => { __trickle_sendTimer = null; __trickle_flush(); }, 300);`,
1415
+ ` __trickle_sendTimer = setTimeout(function() { __trickle_sendTimer = null; __trickle_flush(); }, 300);`,
1388
1416
  ` }`,
1389
1417
  `}`,
1390
1418
  );
@@ -1643,7 +1671,16 @@ export function transformEsmSource(
1643
1671
  result = result.slice(0, position) + code + result.slice(position);
1644
1672
  }
1645
1673
 
1646
- return prefix + result;
1674
+ // Preserve 'use client' / 'use server' directives — they must be the first expression
1675
+ // in the file (before any imports or code). Extract them from result and prepend before prefix.
1676
+ let directive = '';
1677
+ const directiveMatch = result.match(/^(\s*(?:'use client'|"use client"|'use server'|"use server")\s*;?\s*\n?)/);
1678
+ if (directiveMatch) {
1679
+ directive = directiveMatch[1];
1680
+ result = result.slice(directiveMatch[0].length);
1681
+ }
1682
+
1683
+ return directive + prefix + result;
1647
1684
  }
1648
1685
 
1649
1686
  export default tricklePlugin;