@probat/react 0.1.2 → 0.1.4

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/src/utils/api.ts CHANGED
@@ -15,6 +15,8 @@ export type ComponentVariantInfo = {
15
15
 
16
16
  export type ComponentExperimentConfig = {
17
17
  proposal_id: string;
18
+ repo_full_name: string;
19
+ base_ref?: string; // Git branch/ref (default: "main")
18
20
  variants: Record<string, ComponentVariantInfo>;
19
21
  };
20
22
 
@@ -82,7 +84,7 @@ export async function sendMetric(
82
84
  captured_at: new Date().toISOString(),
83
85
  };
84
86
  try {
85
- const response = await fetch(url, {
87
+ await fetch(url, {
86
88
  method: "POST",
87
89
  headers: {
88
90
  Accept: "application/json",
@@ -91,29 +93,8 @@ export async function sendMetric(
91
93
  credentials: "include", // CRITICAL: Include cookies to distinguish different users
92
94
  body: JSON.stringify(body),
93
95
  });
94
-
95
- // Log in development for debugging
96
- if (!response.ok) {
97
- console.warn('[PROBAT] Metric send failed:', {
98
- status: response.status,
99
- statusText: response.statusText,
100
- url,
101
- body,
102
- });
103
- } else {
104
- console.log('[PROBAT] Metric sent successfully:', {
105
- metricName,
106
- proposalId,
107
- variantLabel,
108
- });
109
- }
110
- } catch (error) {
111
- // Log error in development, but don't break the app
112
- console.error('[PROBAT] Error sending metric:', {
113
- error: error instanceof Error ? error.message : String(error),
114
- url,
115
- body,
116
- });
96
+ } catch {
97
+ // Silently fail - metrics should not break the app
117
98
  }
118
99
  }
119
100
 
@@ -197,6 +178,7 @@ export async function fetchComponentExperimentConfig(
197
178
 
198
179
  // Cache for variant component loads
199
180
  const variantComponentCache = new Map<string, Promise<React.ComponentType<any> | null>>();
181
+ const moduleCache = new Map<string, any>(); // Cache for loaded modules (relative imports)
200
182
 
201
183
  // Make React available globally for variant components
202
184
  if (typeof window !== "undefined") {
@@ -204,11 +186,146 @@ if (typeof window !== "undefined") {
204
186
  (window as any).React = (window as any).React || React;
205
187
  }
206
188
 
189
+ /**
190
+ * Resolve relative import path to absolute path
191
+ */
192
+ function resolveRelativePath(relativePath: string, basePath: string): string {
193
+ // Remove file extension if present
194
+ const baseDir = basePath.substring(0, basePath.lastIndexOf('/'));
195
+ const parts = baseDir.split('/').filter(Boolean);
196
+ const relativeParts = relativePath.split('/').filter(Boolean);
197
+
198
+ for (const part of relativeParts) {
199
+ if (part === '..') {
200
+ parts.pop();
201
+ } else if (part !== '.') {
202
+ parts.push(part);
203
+ }
204
+ }
205
+
206
+ return '/' + parts.join('/');
207
+ }
208
+
209
+ /**
210
+ * Fetch and compile a module from absolute path
211
+ */
212
+ async function loadRelativeModule(
213
+ absolutePath: string,
214
+ baseFilePath: string,
215
+ repoFullName?: string,
216
+ baseRef?: string
217
+ ): Promise<any> {
218
+ const cacheKey = `${baseFilePath}:${absolutePath}`;
219
+ if (moduleCache.has(cacheKey)) {
220
+ return moduleCache.get(cacheKey);
221
+ }
222
+
223
+ // Try to determine file extension
224
+ const extensions = ['.jsx', '.tsx', '.js', '.ts'];
225
+ let moduleCode: string | null = null;
226
+ let modulePath: string | null = null;
227
+
228
+ // Try each extension
229
+ for (const ext of extensions) {
230
+ const testPath = absolutePath + (absolutePath.includes('.') ? '' : ext);
231
+
232
+ // Try local first
233
+ try {
234
+ const localRes = await fetch(testPath, {
235
+ method: "GET",
236
+ headers: { Accept: "text/plain" },
237
+ });
238
+ if (localRes.ok) {
239
+ moduleCode = await localRes.text();
240
+ modulePath = testPath;
241
+ break;
242
+ }
243
+ } catch {
244
+ // Continue to next extension or GitHub
245
+ }
246
+
247
+ // Try GitHub if local failed
248
+ if (!moduleCode && repoFullName) {
249
+ try {
250
+ const githubPath = testPath.startsWith('/') ? testPath.substring(1) : testPath;
251
+ const githubUrl = `https://raw.githubusercontent.com/${repoFullName}/${baseRef || 'main'}/${githubPath}`;
252
+ const res = await fetch(githubUrl, { method: "GET", headers: { Accept: "text/plain" } });
253
+ if (res.ok) {
254
+ moduleCode = await res.text();
255
+ modulePath = testPath;
256
+ break;
257
+ }
258
+ } catch {
259
+ // Continue
260
+ }
261
+ }
262
+ }
263
+
264
+ if (!moduleCode || !modulePath) {
265
+ throw new Error(`Could not resolve module: ${absolutePath} from ${baseFilePath}`);
266
+ }
267
+
268
+ // Compile the module
269
+ let Babel: any;
270
+ if (typeof window !== "undefined" && (window as any).Babel) {
271
+ Babel = (window as any).Babel;
272
+ } else {
273
+ try {
274
+ // @ts-ignore
275
+ const babelModule = await import("@babel/standalone");
276
+ Babel = babelModule.default || babelModule;
277
+ } catch {
278
+ throw new Error("Babel not available for compiling relative import");
279
+ }
280
+ }
281
+
282
+ // Preprocess module code
283
+ let processedCode = moduleCode;
284
+ processedCode = processedCode.replace(/^import\s+['"].*\.css['"];?\s*$/gm, "");
285
+ processedCode = processedCode.replace(/^import\s+React\s+from\s+['"]react['"];?\s*$/m, "const React = window.React || globalThis.React;");
286
+ processedCode = processedCode.replace(/import\.meta\.env\.[\w$]+/g, "undefined");
287
+ processedCode = processedCode.replace(/\bimport\.meta\b/g, "({})");
288
+
289
+ const isTSX = modulePath.endsWith(".tsx");
290
+ const compiled = Babel.transform(processedCode, {
291
+ presets: [
292
+ ["react", { runtime: "classic" }],
293
+ ["typescript", { allExtensions: true, isTSX }],
294
+ ],
295
+ plugins: [["transform-modules-commonjs", { allowTopLevelThis: true }]],
296
+ sourceType: "module",
297
+ filename: modulePath,
298
+ }).code;
299
+
300
+ // Execute compiled module
301
+ const moduleCodeWrapper = `
302
+ var require = function(name) {
303
+ if (name === "react" || name === "react/jsx-runtime") {
304
+ return window.React || globalThis.React;
305
+ }
306
+ if (name.startsWith("/@vite") || name.includes("@vite/client")) {
307
+ return {};
308
+ }
309
+ throw new Error("Unsupported module in relative import: " + name);
310
+ };
311
+ var module = { exports: {} };
312
+ var exports = module.exports;
313
+ ${compiled}
314
+ return module.exports;
315
+ `;
316
+
317
+ const moduleExports = new Function(moduleCodeWrapper)();
318
+ moduleCache.set(cacheKey, moduleExports);
319
+ return moduleExports;
320
+ }
321
+
207
322
  export async function loadVariantComponent(
208
323
  baseUrl: string,
209
324
  proposalId: string,
210
325
  experimentId: string,
211
- filePath: string | null
326
+ filePath: string | null,
327
+ repoFullName?: string,
328
+ baseRef?: string
212
329
  ): Promise<React.ComponentType<any> | null> {
213
330
  if (!filePath) {
214
331
  return null;
@@ -225,29 +342,220 @@ export async function loadVariantComponent(
225
342
  // Create new load promise
226
343
  const loadPromise = (async () => {
227
344
  try {
228
- // Fetch the variant code (server compiles TSX/JSX to JS)
229
- const variantUrl = `${baseUrl.replace(/\/$/, "")}/variants/${filePath}`;
345
+ // ============================================
346
+ // Hybrid approach: Dynamic import for CSR (Vite), fetch+babel for SSR (Next.js)
347
+ // ============================================
348
+ let code: string = "";
349
+ let rawCode: string = "";
350
+ let rawCodeFetched = false;
351
+
352
+ // Try dynamic import first (works great for Vite/CSR, resolves relative imports automatically)
353
+ // Only attempt in browser and not during Next.js server-side compilation
354
+ const isBrowser = typeof window !== "undefined";
355
+ const isNextJSServer = typeof window === "undefined" ||
356
+ (typeof (globalThis as any).process !== "undefined" && (globalThis as any).process.env?.NEXT_RUNTIME === "nodejs");
357
+
358
+ if (isBrowser && !isNextJSServer) {
359
+ try {
360
+ const variantUrl = `/probat/${filePath}`;
361
+ // Use dynamic import - Vite can resolve relative imports automatically
362
+ const mod = await import(/* @vite-ignore */ variantUrl);
363
+ const VariantComponent = (mod as any)?.default || mod;
364
+ if (VariantComponent && typeof VariantComponent === "function") {
365
+ console.log(`[PROBAT] ✅ Loaded variant via dynamic import (CSR): ${variantUrl}`);
366
+ return VariantComponent as React.ComponentType<any>;
367
+ }
368
+ } catch (dynamicImportError) {
369
+ // Dynamic import failed (might be Next.js or other issue), fall through to fetch+babel
370
+ console.debug(`[PROBAT] Dynamic import failed, using fetch+babel:`, dynamicImportError);
371
+ }
372
+ }
230
373
 
231
- const res = await fetch(variantUrl, {
232
- method: "GET",
233
- headers: { Accept: "text/javascript" },
234
- credentials: "include",
235
- });
374
+ // Fallback: fetch + babel compilation (works for Next.js SSR and when dynamic import fails)
375
+ const localUrl = `/probat/${filePath}`;
376
+ try {
377
+ const localRes = await fetch(localUrl, {
378
+ method: "GET",
379
+ headers: { Accept: "text/plain" },
380
+ });
381
+ if (localRes.ok) {
382
+ rawCode = await localRes.text();
383
+ rawCodeFetched = true;
384
+ console.log(`[PROBAT] ✅ Loaded variant from local (user's repo): ${localUrl}`);
385
+ }
386
+ } catch {
387
+ console.debug(`[PROBAT] Local file not available (${localUrl}), trying GitHub...`);
388
+ }
236
389
 
237
- if (!res.ok) {
238
- throw new Error(`HTTP ${res.status}`);
390
+ if (!rawCodeFetched && repoFullName) {
391
+ const githubPath = `probat/${filePath}`;
392
+ const gitRef = baseRef || "main";
393
+ const githubUrl = `https://raw.githubusercontent.com/${repoFullName}/${gitRef}/${githubPath}`;
394
+ const res = await fetch(githubUrl, { method: "GET", headers: { Accept: "text/plain" } });
395
+ if (res.ok) {
396
+ rawCode = await res.text();
397
+ rawCodeFetched = true;
398
+ console.log(`[PROBAT] ⚠️ Loaded variant from GitHub (fallback): ${githubUrl}`);
399
+ } else {
400
+ console.warn(`[PROBAT] ⚠️ GitHub fetch failed (${res.status}), falling back to server compilation`);
401
+ }
239
402
  }
240
403
 
241
- const code = await res.text();
404
+ if (rawCodeFetched && rawCode) {
405
+ let Babel: any;
406
+ if (typeof window !== "undefined" && (window as any).Babel) {
407
+ Babel = (window as any).Babel;
408
+ } else {
409
+ try {
410
+ // @ts-ignore
411
+ const babelModule = await import("@babel/standalone");
412
+ Babel = babelModule.default || babelModule;
413
+ } catch (importError) {
414
+ try {
415
+ await new Promise<void>((resolve, reject) => {
416
+ if (typeof document === "undefined") {
417
+ reject(new Error("Document not available"));
418
+ return;
419
+ }
420
+ if ((window as any).Babel) {
421
+ Babel = (window as any).Babel;
422
+ resolve();
423
+ return;
424
+ }
425
+ const script = document.createElement("script");
426
+ script.src = "https://unpkg.com/@babel/standalone/babel.min.js";
427
+ script.async = true;
428
+ script.onload = () => {
429
+ Babel = (window as any).Babel;
430
+ if (!Babel) reject(new Error("Babel not found after script load"));
431
+ else resolve();
432
+ };
433
+ script.onerror = () => reject(new Error("Failed to load Babel from CDN"));
434
+ document.head.appendChild(script);
435
+ });
436
+ } catch (babelError) {
437
+ console.error("[PROBAT] Failed to load Babel, falling back to server compilation", babelError);
438
+ rawCodeFetched = false;
439
+ }
440
+ }
441
+ }
442
+
443
+ if (rawCodeFetched && rawCode && Babel) {
444
+ const isTSX = filePath.endsWith(".tsx");
445
+ rawCode = rawCode.replace(/^import\s+['"].*\.css['"];?\s*$/gm, "");
446
+ rawCode = rawCode.replace(/^import\s+.*from\s+['"].*\.css['"];?\s*$/gm, "");
447
+ rawCode = rawCode.replace(
448
+ /^import\s+React(?:\s*,\s*\{[^}]*\})?\s+from\s+['"]react['"];?\s*$/m,
449
+ "const React = window.React || globalThis.React;"
450
+ );
451
+ rawCode = rawCode.replace(
452
+ /^import\s+\*\s+as\s+React\s+from\s+['"]react['"];?\s*$/m,
453
+ "const React = window.React || globalThis.React;"
454
+ );
455
+ rawCode = rawCode.replace(
456
+ /^import\s+\{([^}]+)\}\s+from\s+['"]react['"];?\s*$/m,
457
+ (match, imports) => `const {${imports}} = window.React || globalThis.React;`
458
+ );
459
+ rawCode = rawCode.replace(/import\.meta\.env\.[\w$]+/g, "undefined");
460
+ rawCode = rawCode.replace(/\bimport\.meta\b/g, "({})");
461
+ rawCode = rawCode.replace(/^import\s+.*\/@vite\/client.*$/gm, "");
462
+ rawCode = rawCode.replace(/import\.meta\.hot(?:\.[\w$]+)*/g, "undefined");
463
+
464
+ // Preprocess relative imports: convert to absolute paths that can be fetched
465
+ // This allows the require shim to fetch them later
466
+ const relativeImportMap = new Map<string, string>();
467
+ rawCode = rawCode.replace(
468
+ /^import\s+(\w+)\s+from\s+['"](\.\.?\/[^'"]+)['"];?\s*$/gm,
469
+ (match, importName, relativePath) => {
470
+ // Resolve relative path to absolute
471
+ const baseDir = filePath.substring(0, filePath.lastIndexOf('/'));
472
+ const resolvedPath = resolveRelativePath(relativePath, baseDir);
473
+ // Try to find the file with extension
474
+ const absolutePath = resolvedPath.startsWith('/') ? resolvedPath : '/' + resolvedPath;
475
+ relativeImportMap.set(importName, absolutePath);
476
+ // Replace with absolute path import - Babel will compile this to require()
477
+ return `import ${importName} from "${absolutePath}";`;
478
+ }
479
+ );
480
+
481
+ const compiled = Babel.transform(rawCode, {
482
+ presets: [
483
+ ["react", { runtime: "classic" }],
484
+ ["typescript", { allExtensions: true, isTSX }],
485
+ ],
486
+ plugins: [["transform-modules-commonjs", { allowTopLevelThis: true }]],
487
+ sourceType: "module",
488
+ filename: filePath,
489
+ }).code;
490
+
491
+ // Pre-load relative imports if any
492
+ const relativeModules: Record<string, any> = {};
493
+ if (relativeImportMap.size > 0) {
494
+ for (const [importName, absolutePath] of relativeImportMap.entries()) {
495
+ try {
496
+ const moduleExports = await loadRelativeModule(absolutePath, filePath, repoFullName, baseRef);
497
+ relativeModules[absolutePath] = moduleExports.default || moduleExports;
498
+ } catch (err) {
499
+ console.warn(`[PROBAT] Failed to load relative import ${absolutePath}:`, err);
500
+ relativeModules[absolutePath] = null;
501
+ }
502
+ }
503
+ }
504
+
505
+ // Build require shim that can resolve relative imports
506
+ const relativeModulesJson = JSON.stringify(relativeModules);
507
+ code = `
508
+ var __probatVariant = (function() {
509
+ var relativeModules = ${relativeModulesJson};
510
+ var require = function(name) {
511
+ if (name === "react" || name === "react/jsx-runtime") {
512
+ return window.React || globalThis.React;
513
+ }
514
+ if (name.startsWith("/@vite") || name.includes("@vite/client") || name.includes(".vite/deps")) {
515
+ return {};
516
+ }
517
+ if (name === "react/jsx-runtime.js") {
518
+ return window.React || globalThis.React;
519
+ }
520
+ // Handle relative imports (now converted to absolute paths)
521
+ if (name.startsWith("/") && relativeModules.hasOwnProperty(name)) {
522
+ var mod = relativeModules[name];
523
+ if (mod === null) {
524
+ throw new Error("Failed to load module: " + name);
525
+ }
526
+ return mod;
527
+ }
528
+ throw new Error("Unsupported module: " + name);
529
+ };
530
+ var module = { exports: {} };
531
+ var exports = module.exports;
532
+ ${compiled}
533
+ return module.exports.default || module.exports;
534
+ })();
535
+ `;
536
+ } else {
537
+ rawCodeFetched = false;
538
+ code = "";
539
+ }
540
+ }
541
+
542
+ if (!rawCodeFetched || code === "") {
543
+ const variantUrl = `${baseUrl.replace(/\/$/, "")}/variants/${filePath}`;
544
+ const serverRes = await fetch(variantUrl, {
545
+ method: "GET",
546
+ headers: { Accept: "text/javascript" },
547
+ credentials: "include",
548
+ });
549
+ if (!serverRes.ok) {
550
+ throw new Error(`HTTP ${serverRes.status}`);
551
+ }
552
+ code = await serverRes.text();
553
+ }
242
554
 
243
- // The server returns an IIFE that assigns to __probatVariant
244
- // Execute the code to get the component
245
- // First, ensure React is available globally
246
555
  if (typeof window !== "undefined") {
247
556
  (window as any).React = (window as any).React || React;
248
557
  }
249
558
 
250
- // Execute the IIFE code
251
559
  const evalFunc = new Function(`
252
560
  var __probatVariant;
253
561
  ${code}
@@ -255,10 +563,7 @@ export async function loadVariantComponent(
255
563
  `);
256
564
 
257
565
  const result = evalFunc();
258
-
259
- // The result is { default: Component } from esbuild's IIFE output
260
566
  const VariantComponent = result?.default || result;
261
-
262
567
  if (typeof VariantComponent === "function") {
263
568
  return VariantComponent as React.ComponentType<any>;
264
569
  }