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.
- package/dist/next-loader.d.ts +2 -0
- package/dist/next-loader.js +6 -3
- package/dist/next-plugin.d.ts +3 -0
- package/dist/next-plugin.js +72 -2
- package/dist/vite-plugin.d.ts +3 -1
- package/dist/vite-plugin.js +30 -3
- package/dist-esm/vite-plugin.js +30 -3
- package/package.json +1 -1
- package/src/next-loader.ts +8 -3
- package/src/next-plugin.ts +79 -2
- package/src/vite-plugin.ts +39 -2
package/dist/next-loader.d.ts
CHANGED
package/dist/next-loader.js
CHANGED
|
@@ -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
|
-
//
|
|
41
|
-
const
|
|
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
|
}
|
package/dist/next-plugin.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/next-plugin.js
CHANGED
|
@@ -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
|
});
|
package/dist/vite-plugin.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/vite-plugin.js
CHANGED
|
@@ -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(()
|
|
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
|
-
|
|
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;
|
package/dist-esm/vite-plugin.js
CHANGED
|
@@ -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(()
|
|
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
|
-
|
|
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
package/src/next-loader.ts
CHANGED
|
@@ -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
|
-
//
|
|
47
|
-
const
|
|
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 {
|
package/src/next-plugin.ts
CHANGED
|
@@ -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
|
});
|
package/src/vite-plugin.ts
CHANGED
|
@@ -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(()
|
|
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
|
-
|
|
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;
|