@dropout-ai/runtime 0.5.1 → 0.5.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +171 -158
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropout-ai/runtime",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Invisible Node.js runtime for understanding behavioral impact of AI interactions.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
package/src/index.js CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * @dropout-ai/runtime
3
3
  * Universal AI Interaction Capture for Node.js
4
- * Stability: Production (Async-Safe, Zero Config)
4
+ * Stability: High (Browser-Safe, Next.js Safe)
5
5
  */
6
6
 
7
7
  let https, http, crypto;
8
- const { AsyncLocalStorage } = require('async_hooks');
9
8
 
10
9
  // --- DEFAULT CONFIGURATION ---
11
10
  const SUPABASE_FUNCTION_URL =
@@ -13,43 +12,43 @@ const SUPABASE_FUNCTION_URL =
13
12
 
14
13
  // Known AI Domains
15
14
  const KNOWN_AI_DOMAINS = [
16
- 'api.openai.com',
17
- 'api.anthropic.com',
18
- 'generativelanguage.googleapis.com',
19
- 'aiplatform.googleapis.com',
20
- 'api.groq.com',
21
- 'api.mistral.ai',
22
- 'api.cohere.ai'
15
+ "api.openai.com",
16
+ "api.anthropic.com",
17
+ "generativelanguage.googleapis.com",
18
+ "aiplatform.googleapis.com",
19
+ "api.groq.com",
20
+ "api.mistral.ai",
21
+ "api.cohere.ai",
23
22
  ];
24
23
 
25
24
  class Dropout {
26
25
  static instance = null;
27
26
 
28
27
  static init(config) {
29
- if (!Dropout.instance) {
30
- new Dropout(config);
31
- }
28
+ if (!Dropout.instance) new Dropout(config);
32
29
  return Dropout.instance;
33
30
  }
34
31
 
35
32
  constructor(config = {}) {
36
33
  if (Dropout.instance) return Dropout.instance;
37
34
 
38
- // 🛡️ Browser Guard
39
- if (typeof window !== 'undefined') return;
35
+ // 🛡️ Browser Guard (prevents client execution)
36
+ if (typeof window !== "undefined" && typeof window.document !== "undefined") {
37
+ return;
38
+ }
40
39
 
41
- // 🛡️ Credentials Guard
40
+ // 🛡️ Credential Guard
42
41
  if (!config.apiKey || !config.projectId) {
43
42
  if (config.debug) {
44
- console.warn("[Dropout] 🔴 Missing API Key or Project ID.");
43
+ console.warn("[Dropout] Missing API Key or Project ID");
45
44
  }
46
45
  return;
47
46
  }
48
47
 
49
48
  try {
50
- https = require('https');
51
- http = require('http');
52
- crypto = require('crypto');
49
+ https = require("https");
50
+ http = require("http");
51
+ crypto = require("crypto");
53
52
  } catch {
54
53
  return;
55
54
  }
@@ -57,89 +56,100 @@ class Dropout {
57
56
  this.projectId = config.projectId;
58
57
  this.apiKey = config.apiKey;
59
58
  this.debug = config.debug || false;
60
- this.privacy = config.privacy || 'full';
59
+ this.privacy = config.privacy || "full";
61
60
  this.captureEndpoint = config.captureEndpoint || SUPABASE_FUNCTION_URL;
62
61
  this.maxOutputBytes = 32768;
63
62
 
64
- // ✅ ASYNC CONTEXT STORE (THE FIX)
65
- this.ctx = new AsyncLocalStorage();
63
+ // --------------------------------------------------
64
+ // ASYNC CONTEXT (LAZY, NODE-ONLY)
65
+ // --------------------------------------------------
66
+ this.storage = null;
67
+
68
+ try {
69
+ const asyncHooks = require("async_hooks");
70
+ this.storage = new asyncHooks.AsyncLocalStorage();
71
+ } catch {
72
+ // Edge / Browser / Unsupported — safely disabled
73
+ this.storage = null;
74
+ }
66
75
 
76
+ // --------------------------------------------------
77
+ // NETWORK PATCHING
78
+ // --------------------------------------------------
67
79
  this.patchNetwork();
68
80
 
69
81
  if (this.debug) {
70
- console.log(`[Dropout] 🟢 Online | Project: ${this.projectId}`);
82
+ console.log(`[Dropout] 🟢 Online | Project ${this.projectId}`);
71
83
  }
72
84
 
73
85
  Dropout.instance = this;
74
86
  }
75
87
 
76
- // -------------------------
77
- // Utilities
78
- // -------------------------
79
- log(msg, ...args) {
80
- if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
81
- }
82
-
88
+ // --------------------------------------------------
89
+ // UTILITIES
90
+ // --------------------------------------------------
83
91
  generateSessionId() {
84
92
  try {
85
93
  return crypto.randomUUID();
86
94
  } catch {
87
- return 'sess_' + Date.now().toString(36);
95
+ return "sess_" + Date.now().toString(36);
88
96
  }
89
97
  }
90
98
 
91
99
  hash(text) {
92
100
  if (!text) return null;
93
101
  try {
94
- return crypto.createHash('sha256')
102
+ return crypto
103
+ .createHash("sha256")
95
104
  .update(text.toLowerCase().trim())
96
- .digest('hex');
105
+ .digest("hex");
97
106
  } catch {
98
- return 'hash_err';
107
+ return "hash_err";
99
108
  }
100
109
  }
101
110
 
102
- // -------------------------
103
- // Session Context Helpers
104
- // -------------------------
105
- getSession() {
106
- let store = this.ctx.getStore();
111
+ // --------------------------------------------------
112
+ // CONTEXT API (OPTIONAL BUT SAFE)
113
+ // --------------------------------------------------
114
+ run(sessionId, callback) {
115
+ if (!this.storage) return callback();
107
116
 
108
- // 🔑 AUTO-CREATE SESSION IF MISSING
109
- if (!store) {
110
- store = {
111
- session_id: this.generateSessionId(),
112
- turn_index: 0,
113
- last_prompt_hash: null,
114
- last_response_hash: null,
115
- last_turn_confirmed: true
116
- };
117
- this.ctx.enterWith(store);
118
- }
117
+ const context = {
118
+ sessionId: sessionId || this.generateSessionId(),
119
+ turnIndex: 0,
120
+ };
119
121
 
120
- return store;
122
+ return this.storage.run(context, callback);
121
123
  }
122
124
 
123
- // -------------------------
124
- // AI Detection
125
- // -------------------------
125
+ getContext() {
126
+ if (!this.storage) return null;
127
+ return this.storage.getStore() || null;
128
+ }
129
+
130
+ getSessionId() {
131
+ const ctx = this.getContext();
132
+ return ctx?.sessionId || "global_session";
133
+ }
134
+
135
+ // --------------------------------------------------
136
+ // NORMALIZATION
137
+ // --------------------------------------------------
126
138
  normalize(url, body) {
127
- let provider = 'custom';
128
- let model = 'unknown';
139
+ let provider = "custom";
140
+ let model = "unknown";
129
141
 
130
142
  try {
131
- if (url) {
132
- const u = url.toLowerCase();
133
- if (u.includes('openai')) provider = 'openai';
134
- else if (u.includes('anthropic')) provider = 'anthropic';
135
- else if (u.includes('google')) provider = 'google';
136
- else if (u.includes('groq')) provider = 'groq';
137
- else if (u.includes('mistral')) provider = 'mistral';
138
- else if (u.includes('cohere')) provider = 'cohere';
139
- }
143
+ const u = url?.toLowerCase() || "";
144
+ if (u.includes("openai")) provider = "openai";
145
+ else if (u.includes("anthropic")) provider = "anthropic";
146
+ else if (u.includes("google")) provider = "google";
147
+ else if (u.includes("groq")) provider = "groq";
148
+ else if (u.includes("mistral")) provider = "mistral";
149
+ else if (u.includes("cohere")) provider = "cohere";
140
150
 
141
151
  if (body) {
142
- const parsed = typeof body === 'string' ? JSON.parse(body) : body;
152
+ const parsed = typeof body === "string" ? JSON.parse(body) : body;
143
153
  if (parsed?.model) model = parsed.model;
144
154
  }
145
155
  } catch { }
@@ -148,9 +158,8 @@ class Dropout {
148
158
  }
149
159
 
150
160
  isAiRequest(url, body) {
151
- if (!url) return false;
152
- if (url.includes(this.captureEndpoint)) return false;
153
- if (KNOWN_AI_DOMAINS.some(d => url.includes(d))) return true;
161
+ if (!url || url.includes(this.captureEndpoint)) return false;
162
+ if (KNOWN_AI_DOMAINS.some((d) => url.includes(d))) return true;
154
163
 
155
164
  if (body) {
156
165
  return (
@@ -158,127 +167,131 @@ class Dropout {
158
167
  (body.includes('"messages"') || body.includes('"prompt"'))
159
168
  );
160
169
  }
161
-
162
170
  return false;
163
171
  }
164
172
 
165
- // -------------------------
166
- // Emit
167
- // -------------------------
173
+ // --------------------------------------------------
174
+ // EMITTER
175
+ // --------------------------------------------------
168
176
  emit(payload) {
169
177
  try {
170
- const req = (this.captureEndpoint.startsWith('http:') ? http : https)
171
- .request(this.captureEndpoint, {
172
- method: 'POST',
173
- headers: {
174
- 'Content-Type': 'application/json',
175
- 'x-dropout-key': this.apiKey
176
- }
177
- });
178
-
179
- req.on('error', () => { });
180
- req.write(JSON.stringify({
178
+ const finalPayload = {
181
179
  project_id: this.projectId,
180
+ session_id: payload.session_id,
181
+ turn_index: payload.turn_index,
182
+ turn_role: payload.turn_role,
183
+ direction:
184
+ payload.turn_role === "user" ? "user_to_ai" : "ai_to_user",
185
+ provider: payload.provider,
186
+ model: payload.model,
187
+ latency_ms: payload.latency_ms || null,
188
+ content: payload.content,
189
+ content_hash: payload.content_hash,
190
+ metadata_flags: payload.metadata_flags,
182
191
  received_at: new Date().toISOString(),
183
- ...payload
184
- }));
192
+ };
193
+
194
+ const req = (this.captureEndpoint.startsWith("http:")
195
+ ? http
196
+ : https
197
+ ).request(this.captureEndpoint, {
198
+ method: "POST",
199
+ headers: {
200
+ "Content-Type": "application/json",
201
+ "x-dropout-key": this.apiKey,
202
+ },
203
+ });
204
+
205
+ req.on("error", () => { });
206
+ req.write(JSON.stringify(finalPayload));
185
207
  req.end();
186
208
  } catch { }
187
209
  }
188
210
 
189
- // -------------------------
190
- // Network Patching
191
- // -------------------------
211
+ // --------------------------------------------------
212
+ // NETWORK PATCHING
213
+ // --------------------------------------------------
192
214
  patchNetwork() {
193
215
  const _this = this;
194
216
 
195
- // ---- FETCH ----
196
- if (global.fetch) {
197
- const originalFetch = global.fetch;
217
+ if (!global.fetch) return;
198
218
 
199
- global.fetch = async function (input, init) {
200
- let url;
201
- try { url = typeof input === 'string' ? input : input?.url; }
202
- catch { return originalFetch.apply(this, arguments); }
219
+ const originalFetch = global.fetch;
203
220
 
204
- let bodyStr = '';
205
- if (init?.body) {
206
- try { bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); }
207
- catch { }
208
- }
221
+ global.fetch = async function (input, init) {
222
+ let url;
223
+ try {
224
+ url = typeof input === "string" ? input : input?.url;
225
+ } catch {
226
+ return originalFetch.apply(this, arguments);
227
+ }
209
228
 
210
- if (!_this.isAiRequest(url, bodyStr)) {
211
- return originalFetch.apply(this, arguments);
212
- }
229
+ let bodyStr = "";
230
+ if (init?.body) {
231
+ try {
232
+ bodyStr =
233
+ typeof init.body === "string"
234
+ ? init.body
235
+ : JSON.stringify(init.body);
236
+ } catch { }
237
+ }
213
238
 
214
- const session = _this.getSession();
215
- const start = Date.now();
216
- const { provider, model } = _this.normalize(url, bodyStr);
217
- const pHash = _this.hash(bodyStr);
239
+ if (!_this.isAiRequest(url, bodyStr)) {
240
+ return originalFetch.apply(this, arguments);
241
+ }
218
242
 
219
- const turn =
220
- session.last_turn_confirmed
221
- ? session.turn_index++
222
- : Math.max(session.turn_index - 1, 0);
243
+ const sessionId = _this.getSessionId();
244
+ const { provider, model } = _this.normalize(url, bodyStr);
245
+ const pHash = _this.hash(bodyStr);
246
+ const start = Date.now();
223
247
 
224
- session.last_turn_confirmed = false;
248
+ _this.emit({
249
+ session_id: sessionId,
250
+ turn_index: 0,
251
+ turn_role: "user",
252
+ provider,
253
+ model,
254
+ content: _this.privacy === "full" ? bodyStr : null,
255
+ content_hash: pHash,
256
+ metadata_flags: { method: "FETCH", url },
257
+ });
258
+
259
+ const response = await originalFetch.apply(this, arguments);
260
+
261
+ try {
262
+ const cloned = response.clone();
263
+ let oText = await cloned.text();
264
+ if (oText.length > _this.maxOutputBytes) {
265
+ oText = oText.slice(0, _this.maxOutputBytes);
266
+ }
225
267
 
226
268
  _this.emit({
227
- session_id: session.session_id,
228
- turn_index: turn,
229
- turn_role: 'user',
230
- direction: 'user_to_ai',
269
+ session_id: sessionId,
270
+ turn_index: 1,
271
+ turn_role: "assistant",
272
+ latency_ms: Date.now() - start,
231
273
  provider,
232
274
  model,
233
- content: _this.privacy === 'full' ? bodyStr : null,
234
- content_hash: pHash,
235
- metadata_flags: {
236
- retry_like: pHash === session.last_prompt_hash ? 1 : 0
237
- }
275
+ content: _this.privacy === "full" ? oText : null,
276
+ content_hash: _this.hash(oText),
277
+ metadata_flags: { status: response.status },
238
278
  });
279
+ } catch { }
239
280
 
240
- session.last_prompt_hash = pHash;
241
-
242
- const response = await originalFetch.apply(this, arguments);
243
- const latency = Date.now() - start;
244
-
245
- try {
246
- const text = await response.clone().text();
247
- const oHash = _this.hash(text);
248
-
249
- _this.emit({
250
- session_id: session.session_id,
251
- turn_index: turn,
252
- turn_role: 'assistant',
253
- direction: 'ai_to_user',
254
- provider,
255
- model,
256
- latency_ms: latency,
257
- content: _this.privacy === 'full' ? text.slice(0, _this.maxOutputBytes) : null,
258
- content_hash: oHash,
259
- metadata_flags: {
260
- non_adaptive_response: oHash === session.last_response_hash ? 1 : 0,
261
- turn_boundary_confirmed: 1,
262
- status: response.status
263
- }
264
- });
265
-
266
- session.last_response_hash = oHash;
267
- session.last_turn_confirmed = true;
268
- } catch { }
269
-
270
- return response;
271
- };
272
- }
281
+ return response;
282
+ };
273
283
  }
274
284
  }
275
285
 
276
- // Auto-init
277
- if (process?.env?.DROPOUT_PROJECT_ID && process?.env?.DROPOUT_API_KEY) {
286
+ // Auto-init via env (safe)
287
+ if (
288
+ typeof process !== "undefined" &&
289
+ process.env.DROPOUT_PROJECT_ID &&
290
+ process.env.DROPOUT_API_KEY
291
+ ) {
278
292
  Dropout.init({
279
293
  projectId: process.env.DROPOUT_PROJECT_ID,
280
294
  apiKey: process.env.DROPOUT_API_KEY,
281
- debug: process.env.DROPOUT_DEBUG === 'true'
282
295
  });
283
296
  }
284
297