File size: 4,789 Bytes
b152fd5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
/**
 * Dynamic UI parser loading for external adapters.
 *
 * When the Paperclip UI encounters an adapter type that doesn't have a
 * built-in parser (e.g., an external adapter loaded via the plugin system),
 * it fetches the parser JS from `/api/adapters/:type/ui-parser.js` and
 * evaluates it to create a `parseStdoutLine` function.
 *
 * The parser module must export:
 *   - `parseStdoutLine(line: string, ts: string): TranscriptEntry[]`
 *   - optionally `createStdoutParser(): { parseLine, reset }` for stateful parsers
 *
 * This is the bridge between the server-side plugin system and the client-side
 * UI rendering. Adapter developers ship a `dist/ui-parser.js` with zero
 * runtime dependencies, and Paperclip's UI loads it on demand.
 */

import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import type { StatefulStdoutParser, StdoutLineParser, StdoutParserFactory } from "./types";

interface DynamicParserModule {
  parseStdoutLine: StdoutLineParser;
  createStdoutParser?: StdoutParserFactory;
}

// Cache of dynamically loaded parsers by adapter type.
// Once loaded, the parser is reused for all runs of that adapter type.
const dynamicParserCache = new Map<string, DynamicParserModule>();

// Track which types we've already attempted to load (to avoid repeat 404s).
const failedLoads = new Set<string>();

/**
 * Dynamically load a UI parser for an adapter type from the server API.
 *
 * Fetches `/api/adapters/:type/ui-parser.js`, evaluates the module source
 * in a scoped context, and extracts the `parseStdoutLine` export.
 *
 * @returns A StdoutLineParser function, or null if unavailable.
 */
export async function loadDynamicParser(adapterType: string): Promise<DynamicParserModule | null> {
  // Return cached parser if already loaded
  const cached = dynamicParserCache.get(adapterType);
  if (cached) return cached;

  // Don't retry types that previously 404'd
  if (failedLoads.has(adapterType)) return null;

  try {
    const response = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/ui-parser.js`);
    if (!response.ok) {
      failedLoads.add(adapterType);
      return null;
    }

    const source = await response.text();

    // Evaluate the module source using URL.createObjectURL + dynamic import().
    // This properly supports ESM modules with `export` statements.
    // (new Function("exports", source) would fail with SyntaxError on `export` keywords.)
    const blob = new Blob([source], { type: "application/javascript" });
    const blobUrl = URL.createObjectURL(blob);

    let parserModule: DynamicParserModule;

    try {
      const mod = await import(/* @vite-ignore */ blobUrl);

      // Prefer the factory function (stateful parser) if available,
      // fall back to the static parseStdoutLine function.
      if (typeof mod.createStdoutParser === "function") {
        const createStdoutParser = mod.createStdoutParser as StdoutParserFactory;
        parserModule = {
          createStdoutParser,
          // Fallback for callers that only know about parseStdoutLine.
          parseStdoutLine:
            typeof mod.parseStdoutLine === "function"
              ? (mod.parseStdoutLine as StdoutLineParser)
              : ((line: string, ts: string) => {
                  const parser = createStdoutParser() as StatefulStdoutParser;
                  const entries = parser.parseLine(line, ts);
                  parser.reset();
                  return entries;
                }),
        };
      } else if (typeof mod.parseStdoutLine === "function") {
        parserModule = {
          parseStdoutLine: mod.parseStdoutLine as StdoutLineParser,
        };
      } else {
        console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`);
        failedLoads.add(adapterType);
        return null;
      }
    } finally {
      URL.revokeObjectURL(blobUrl);
    }

    // Cache for reuse
    dynamicParserCache.set(adapterType, parserModule);
    console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`);
    return parserModule;
  } catch (err) {
    console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err);
    failedLoads.add(adapterType);
    return null;
  }
}

/**
 * Invalidate a cached dynamic parser, removing it from both the parser cache
 * and the failed-loads set so that the next load attempt will try again.
 */
export function invalidateDynamicParser(adapterType: string): boolean {
  const wasCached = dynamicParserCache.has(adapterType);
  dynamicParserCache.delete(adapterType);
  failedLoads.delete(adapterType);
  if (wasCached) {
    console.info(`[adapter-ui-loader] Invalidated dynamic UI parser for "${adapterType}"`);
  }
  return wasCached;
}