trickle-observe 0.2.73 → 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)
@@ -1339,9 +1341,13 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1339
1341
  // SSR/Node.js mode — write directly to file system
1340
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) {}`, `}`);
1341
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
+ }
1342
1348
  else {
1343
1349
  // 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);`, ` }`, `}`);
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);`, ` }`, `}`);
1345
1351
  }
1346
1352
  }
1347
1353
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
@@ -1453,6 +1459,14 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1453
1459
  for (const { position, code } of allInsertions) {
1454
1460
  result = result.slice(0, position) + code + result.slice(position);
1455
1461
  }
1456
- 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;
1457
1471
  }
1458
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)
@@ -1332,9 +1334,13 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1332
1334
  // SSR/Node.js mode — write directly to file system
1333
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) {}`, `}`);
1334
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
+ }
1335
1341
  else {
1336
1342
  // 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);`, ` }`, `}`);
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);`, ` }`, `}`);
1338
1344
  }
1339
1345
  }
1340
1346
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
@@ -1446,6 +1452,14 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1446
1452
  for (const { position, code } of allInsertions) {
1447
1453
  result = result.slice(0, position) + code + result.slice(position);
1448
1454
  }
1449
- 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;
1450
1464
  }
1451
1465
  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.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);
@@ -1370,6 +1372,24 @@ export function transformEsmSource(
1370
1372
  ` } catch(e) {}`,
1371
1373
  `}`,
1372
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
+ );
1373
1393
  } else {
1374
1394
  // Browser mode — buffer and send via Vite HMR WebSocket
1375
1395
  prefixLines.push(
@@ -1384,7 +1404,7 @@ export function transformEsmSource(
1384
1404
  `function __trickle_send(line) {`,
1385
1405
  ` __trickle_sendBuf.push(line);`,
1386
1406
  ` if (!__trickle_sendTimer) {`,
1387
- ` __trickle_sendTimer = setTimeout(() => { __trickle_sendTimer = null; __trickle_flush(); }, 300);`,
1407
+ ` __trickle_sendTimer = setTimeout(function() { __trickle_sendTimer = null; __trickle_flush(); }, 300);`,
1388
1408
  ` }`,
1389
1409
  `}`,
1390
1410
  );
@@ -1643,7 +1663,16 @@ export function transformEsmSource(
1643
1663
  result = result.slice(0, position) + code + result.slice(position);
1644
1664
  }
1645
1665
 
1646
- 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;
1647
1676
  }
1648
1677
 
1649
1678
  export default tricklePlugin;