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.
- 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 +34 -27
- package/dist-esm/vite-plugin.js +34 -27
- package/package.json +1 -1
- package/src/next-loader.ts +8 -3
- package/src/next-plugin.ts +79 -2
- package/src/vite-plugin.ts +70 -27
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
|
@@ -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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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(()
|
|
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
|
-
|
|
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;
|
package/dist-esm/vite-plugin.js
CHANGED
|
@@ -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
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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(()
|
|
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
|
-
|
|
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
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
|
@@ -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
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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 {
|
|
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
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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(()
|
|
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
|
-
|
|
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;
|