Setting the file. One moment.
Emit · Browser To API · browserbase/skills · Skills Docs
ContentsBack to the top of the page export function emit
— line 203
This file
Number 4.2
Position 2 of 14
Type JavaScript
Size 32 KB
Lines 783 scripts/ emit.mjs
JavaScript · 783 lines · 32 KB
11
12 function confidenceBucket ( ep ) {
13 const s = ep.sampleCount;
14 const flagged = ep.normalizationFlags. length > 0 ;
15 const multiStatus = ep.statusCodes. length >= 2 ;
16 if (s <= 2 || flagged) return 'low' ;
17 if (s >= 10 && multiStatus) return 'high' ;
18 return 'medium' ;
19 }
20
21 // Hoist structurally-identical inline schemas into components.schemas. We use a
22 // stable structural hash and bias names off the endpoint path so refs are
23 // readable (e.g. "Item" instead of "Schema7"). Recurses into nested object/array
24 // schemas so a Post that appears once at the top level and once as the items of
25 // a list still hoists as a single component.
26 function buildComponents ( endpoints ) {
27 const byHash = new Map (); // hash -> { name, schema, hint }
28 const refCount = new Map (); // hash -> count of sites referencing it
29
30 function isObjectSchema ( s ) {
31 if ( ! s || typeof s !== 'object' ) return false ;
32 if (s.type === 'object' ) return true ;
33 if (Array. isArray (s.type) && s.type. includes ( 'object' )) return true ;
34 return false ;
35 }
36 function isArraySchema ( s ) {
37 if ( ! s || typeof s !== 'object' ) return false ;
38 if (s.type === 'array' ) return true ;
39 if (Array. isArray (s.type) && s.type. includes ( 'array' )) return true ;
40 return false ;
41 }
42
43 function visit ( schema , hint ) {
44 if ( ! schema || typeof schema !== 'object' ) return ;
45 if ( isObjectSchema (schema)) {
46 const h = structuralHash (schema);
47 refCount. set (h, (refCount. get (h) || 0 ) + 1 );
48 if ( ! byHash. has (h)) byHash. set (h, { name: null , schema, hint });
49 for ( const [ k , child ] of Object. entries (schema.properties || {})) {
50 visit (child, propHint (hint, k));
51 }
52 } else if ( isArraySchema (schema) && schema.items) {
53 visit (schema.items, hint);
54 }
55 }
56
57 for ( const ep of endpoints) {
58 if (ep.requestSchema) visit (ep.requestSchema, schemaHintFromPath (ep.path) + 'Request' );
59 for ( const [, sch ] of Object. entries (ep.responseSchemas || {})) {
60 visit (sch, schemaHintFromPath (ep.path));
61 }
62 }
63
64 // Hoist when (a) referenced by ≥ 2 sites, OR (b) it's an object with ≥ 4 properties.
65 const components = {};
66 let counter = 0 ;
67 for ( const [ h , info ] of byHash. entries ()) {
68 const refs = refCount. get (h) || 0 ;
69 const propCount = Object. keys (info.schema.properties || {}). length ;
70 if (refs < 2 && propCount < 4 ) continue ;
71 let name = info.hint || `Schema${ ++ counter }` ;
72 if (components[name]) name = `${ name }_${ ++ counter }` ;
73 info.name = name;
74 components[name] = info.schema;
75 }
76
77 // refOrInline rewrites a schema, replacing any nested object schema that
78 // matches a hoisted component with a $ref. Arrays have their items rewritten.
79 function refOrInline ( schema ) {
80 if ( ! schema || typeof schema !== 'object' ) return schema;
81 if ( isObjectSchema (schema)) {
82 const h = structuralHash (schema);
83 const info = byHash. get (h);
84 if (info && info.name) return { $ref: `#/components/schemas/${ info . name }` };
85 if ( ! schema.properties) return schema;
86 const rewritten = { ... schema, properties: {} };
87 for ( const [ k , child ] of Object. entries (schema.properties)) {
88 rewritten.properties[k] = refOrInline (child);
89 }
90 return rewritten;
91 }
92 if ( isArraySchema (schema) && schema.items) {
93 return { ... schema, items: refOrInline (schema.items) };
94 }
95 return schema;
96 }
97
98 // Inline-rewrite the components themselves so nested objects within
99 // components also use $refs.
100 for ( const [ name , sch ] of Object. entries (components)) {
101 if (sch.properties) {
102 components[name] = { ... sch, properties: Object. fromEntries (
103 Object. entries (sch.properties). map (([ k , c ]) => [k, refOrInline (c)]),
104 )};
105 }
106 }
107
108 return { components, refOrInline };
109 }
110
111 function propHint ( parentHint , key ) {
112 const cap = key. replace ( / [ ^ A-Za-z0-9] / g , '' ). replace ( / ^ . / , c => c. toUpperCase ());
113 return cap || (parentHint ? parentHint + 'Inner' : 'Schema' );
114 }
115
116 function schemaHintFromPath ( p ) {
117 if ( ! p) return 'Schema' ;
118 const parts = p. split ( '/' ). filter ( s => s && ! s. startsWith ( '{' ));
119 if ( ! parts. length ) return 'Root' ;
120 const last = parts[parts. length - 1 ];
121 return last. replace ( / [ ^ A-Za-z0-9] / g , '' ). replace ( / ^ . / , c => c. toUpperCase ()) || 'Schema' ;
122 }
123
124 function makeOperation ( ep , refOrInline ) {
125 const params = [];
126 for ( const p of ep.pathParams || []) params. push (p);
127 for ( const p of ep.queryParams || []) params. push (p);
128
129 const summary = ep.operationName
130 ? `${ ep . operationName } (${ ep . method } ${ ep . parentPath || ep . path })`
131 : `${ ep . method } ${ ep . path }` ;
132 const op = {
133 summary,
134 operationId: makeOpId (ep),
135 };
136 if (params. length ) op.parameters = params;
137
138 if (ep.requestSchema && (ep.method === 'POST' || ep.method === 'PUT' || ep.method === 'PATCH' || ep.method === 'DELETE' )) {
139 op.requestBody = {
140 content: {
141 [ep.requestContentType || 'application/json' ]: {
142 schema: refOrInline (ep.requestSchema),
143 ... (ep.requestExample ? { example: ep.requestExample } : {}),
144 },
145 },
146 };
147 }
148
149 const responses = {};
150 const statuses = ep.statusCodes. length ? ep.statusCodes : [ 200 ];
151 for ( const status of statuses) {
152 const ct = (ep.responseContentTypes && ep.responseContentTypes[status]) || 'application/json' ;
153 const schema = ep.responseSchemas?.[ String (status)];
154 const entry = { description: defaultDescriptionFor (status) };
155 if (schema || ep.responseExample) {
156 entry.content = {
157 [ct]: {
158 ... (schema ? { schema: refOrInline (schema) } : {}),
159 ... (status === ep.statusCodes[ 0 ] && ep.responseExample ? { example: ep.responseExample } : {}),
160 },
161 };
162 }
163 responses[ String (status)] = entry;
164 }
165 op.responses = responses;
166
167 // Extensions
168 op[ 'x-confidence' ] = {
169 samples: ep.sampleCount,
170 statusCodes: ep.statusCodes,
171 normalizationFlags: ep.normalizationFlags,
172 confidence: confidenceBucket (ep),
173 };
174 op[ 'x-sample-count' ] = ep.sampleCount;
175 if (ep.observedAuthHeaders?. length ) op[ 'x-observed-auth' ] = ep.observedAuthHeaders;
176 op[ 'x-origin' ] = ep.origin;
177
178 return op;
179 }
180
181 function defaultDescriptionFor ( status ) {
182 const n = Number (status);
183 if (n >= 200 && n < 300 ) return 'Success' ;
184 if (n >= 300 && n < 400 ) return 'Redirect' ;
185 if (n === 400 ) return 'Bad request' ;
186 if (n === 401 ) return 'Unauthorized' ;
187 if (n === 403 ) return 'Forbidden' ;
188 if (n === 404 ) return 'Not found' ;
189 if (n >= 400 && n < 500 ) return 'Client error' ;
190 if (n >= 500 ) return 'Server error' ;
191 return `Status ${ status }` ;
192 }
193
194 function makeOpId ( ep ) {
195 if (ep.operationName) {
196 return `${ ep . method . toLowerCase () }_${ ep . operationName . replace ( / [ ^ A-Za-z0-9] / g , '_' ) }` ;
197 }
198 const parts = ep.path. split ( '/' ). filter (Boolean). map ( s => s. replace ( / [{}] / g , '' ));
199 const tail = parts. map ( p => p. replace ( / [ ^ A-Za-z0-9] / g , '_' )). join ( '_' );
200 return `${ ep . method . toLowerCase () }_${ tail || 'root'}` ;
201 }
202
203 export function emit ( outDir , opts = {}) {
204 const minSamples = opts.minSamples || 1 ;
205 const format = opts.format || 'both' ;
206 const titleOverride = opts.title || null ;
207
208 const endpoints = readJsonl ( intermediatePath (outDir, 'endpoints.with-schemas.jsonl' ));
209 const kept = endpoints. filter ( e => e.sampleCount >= minSamples);
210 const dropped = endpoints. filter ( e => e.sampleCount < minSamples);
211
212 // Load raw samples for header extraction (client generation needs them)
213 const samplesByKey = new Map ();
214 for ( const row of readJsonl ( intermediatePath (outDir, 'endpoint-samples.jsonl' ))) {
215 samplesByKey. set (row.endpointKey, row.samples);
216 }
217 // Attach to kept endpoints temporarily for client gen
218 for ( const ep of kept) {
219 ep.sampleRows = samplesByKey. get (ep.endpointKey) || [];
220 }
221
222 // Servers: one entry per distinct origin, sorted by frequency.
223 const originCounts = new Map ();
224 for ( const e of kept) originCounts. set (e.origin, (originCounts. get (e.origin) || 0 ) + e.sampleCount);
225 const servers = [ ... originCounts. entries ()]. sort (( a , b ) => b[ 1 ] - a[ 1 ]). map (([ url ]) => ({ url }));
226
227 const primary = servers[ 0 ]?.url || '' ;
228 const title = titleOverride || (primary ? `${ new URL ( primary ). host } (discovered)` : 'Discovered API' );
229
230 const { components , refOrInline } = buildComponents (kept);
231
232 // Build paths. Decomposed operations (e.g. GraphQL) get a synthetic path
233 // like /dapi/fe/gql#Autocomplete so each operation is a distinct entry.
234 const paths = {};
235 const collisions = {};
236 for ( const ep of kept) {
237 const m = ep.method. toLowerCase ();
238 // Use the path as-is (includes [OpName] for decomposed endpoints)
239 const pathKey = ep.path;
240 if ( ! paths[pathKey]) paths[pathKey] = {};
241 const existing = paths[pathKey][m];
242 if ( ! existing) {
243 paths[pathKey][m] = makeOperation (ep, refOrInline);
244 } else {
245 const key = `${ m } ${ pathKey }` ;
246 if ( ! collisions[key]) collisions[key] = [{ origin: existing[ 'x-origin' ], samples: existing[ 'x-sample-count' ] }];
247 collisions[key]. push ({ origin: ep.origin, samples: ep.sampleCount });
248 if (ep.sampleCount > (existing[ 'x-sample-count' ] || 0 )) {
249 paths[pathKey][m] = makeOperation (ep, refOrInline);
250 }
251 }
252 }
253 for ( const [ key , origins ] of Object. entries (collisions)) {
254 const [ m , ... rest ] = key. split ( ' ' );
255 const p = rest. join ( ' ' );
256 if ( ! paths[p]?.[m]) continue ;
257 const op = paths[p][m];
258 const winner = op[ 'x-origin' ];
259 op[ 'x-also-served-from' ] = origins. filter ( o => o.origin !== winner). map ( o => o.origin);
260 }
261
262 const doc = {
263 openapi: '3.1.0' ,
264 info: {
265 title,
266 version: '0.1.0-discovered' ,
267 description: 'Spec discovered from a browser-trace capture by the browser-to-api skill. Inductive, not contractual — see `report.md` and `x-confidence` extensions for caveats.' ,
268 },
269 servers,
270 paths,
271 };
272 if (Object. keys (components). length ) doc.components = { schemas: components };
273
274 if (format === 'yaml' || format === 'both' ) {
275 writeText (path. join (outDir, 'openapi.yaml' ), toYaml (doc));
276 }
277 if (format === 'json' || format === 'both' ) {
278 writeJson (path. join (outDir, 'openapi.json' ), doc);
279 }
280
281 // confidence.json
282 const confidence = {
283 endpoints: endpoints. map ( ep => ({
284 key: ep.endpointKey,
285 samples: ep.sampleCount,
286 statusCodes: ep.statusCodes,
287 requestBodyKnown: ep.requestBodyKnown,
288 responseBodyKnown: ep.responseBodyKnown,
289 normalizationFlags: ep.normalizationFlags,
290 confidence: confidenceBucket (ep),
291 includedInSpec: ep.sampleCount >= minSamples,
292 })),
293 };
294 writeJson (path. join (outDir, 'confidence.json' ), confidence);
295
296 // report.md
297 const redaction = readJson ( intermediatePath (outDir, 'redaction-stats.json' ), { headers: 0 , bodyKeys: 0 , bodyValues: 0 });
298
299 // client.mjs — generated SDK wrapping each operation as a callable function
300 const clientCode = buildClient ({ kept, servers });
301 if (clientCode) {
302 writeText (path. join (outDir, 'client.mjs' ), clientCode);
303 }
304
305 writeText (path. join (outDir, 'report.md' ), buildReport ({ kept, dropped, servers, redaction, minSamples, hasClient: !! clientCode }));
306
307 // index.html — self-contained visual report
308 writeText (path. join (outDir, 'index.html' ), buildHtmlReport ({ kept, servers, title, clientCode }));
309
310 return {
311 endpoints: kept. length ,
312 droppedLowSample: dropped. length ,
313 servers: servers. length ,
314 components: Object. keys (components). length ,
315 client: !! clientCode,
316 };
317 }
318
319 // ---------------------------------------------------------------------------
320 // Client SDK generation
321 // ---------------------------------------------------------------------------
322
323 function toFnName ( name ) {
324 // Autocomplete → autocomplete, RestaurantsAvailability → restaurantsAvailability
325 return name[ 0 ]. toLowerCase () + name. slice ( 1 );
326 }
327
328 function extractObservedHeaders ( kept ) {
329 // Pull non-standard headers that appeared consistently across requests.
330 // These are often required (CSRF tokens, custom auth, etc.)
331 const candidates = new Map (); // headerName -> { values: Set, count }
332 let totalSamples = 0 ;
333 const skip = new Set ([
334 'content-type' , 'user-agent' , 'accept' , 'accept-encoding' , 'accept-language' ,
335 'referer' , 'origin' , 'host' , 'connection' , 'content-length' ,
336 'sec-ch-ua' , 'sec-ch-ua-mobile' , 'sec-ch-ua-platform' ,
337 'sec-fetch-dest' , 'sec-fetch-mode' , 'sec-fetch-site' ,
338 'cookie' , 'authorization' , 'x-api-key' ,
339 ]);
340 for ( const ep of kept) {
341 const samples = ep.sampleRows || [];
342 for ( const s of samples) {
343 totalSamples ++ ;
344 for ( const [ k , v ] of Object. entries (s.reqHeaders || {})) {
345 const lk = k. toLowerCase ();
346 if (skip. has (lk)) continue ;
347 if ( ! candidates. has (lk)) candidates. set (lk, { name: k, values: new Set (), count: 0 });
348 const c = candidates. get (lk);
349 c.count ++ ;
350 c.values. add (v);
351 }
352 }
353 }
354 // Keep headers present in >50% of requests (likely required)
355 const result = {};
356 for ( const [, c ] of candidates) {
357 if (c.count <= totalSamples * 0.5 ) continue ;
358 if (c.values.size <= 5 ) {
359 result[c.name] = [ ... c.values][ 0 ];
360 } else {
361 // High cardinality (e.g. CSRF tokens, correlation IDs) — include with a
362 // representative value. The header is likely required even if the value varies.
363 result[c.name] = [ ... c.values][ 0 ];
364 }
365 }
366 return result;
367 }
368
369 function buildClient ({ kept , servers }) {
370 const baseUrl = servers[ 0 ]?.url || '' ;
371 const operations = kept. filter ( e => e.operationName);
372 const regular = kept. filter ( e => ! e.operationName);
373
374 if ( ! operations. length && ! regular. length ) return null ;
375
376 // Detect required headers from the trace (e.g. CSRF tokens)
377 const observedHeaders = extractObservedHeaders (kept);
378
379 const lines = [];
380 lines. push ( `// Auto-generated API client from browser-trace capture.` );
381 lines. push ( `// Usage: import { ${ operations . slice ( 0 , 3 ). map ( e => toFnName ( e . operationName )). join ( ', ' ) }${ operations . length > 3 ? ', ...' : ''} } from './client.mjs'; \n ` );
382 lines. push ( `const BASE = '${ baseUrl }'; \n ` );
383
384 lines. push ( `const defaultHeaders = {` );
385 lines. push ( ` 'Content-Type': 'application/json',` );
386 lines. push ( ` 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',` );
387 for ( const [ k , v ] of Object. entries (observedHeaders)) {
388 lines. push ( ` '${ k }': '${ v }',` );
389 }
390 lines. push ( `}; \n ` );
391
392 lines. push ( `async function request(path, { method = 'GET', body, query, headers } = {}) {` );
393 lines. push ( ` let url = BASE + path;` );
394 lines. push ( ` if (query) {` );
395 lines. push ( ` const qs = new URLSearchParams(Object.entries(query).filter(([, v]) => v != null));` );
396 lines. push ( ` if (qs.toString()) url += '?' + qs;` );
397 lines. push ( ` }` );
398 lines. push ( ` const res = await fetch(url, {` );
399 lines. push ( ` method,` );
400 lines. push ( ` headers: { ...defaultHeaders, ...headers },` );
401 lines. push ( ` ...(body ? { body: JSON.stringify(body) } : {}),` );
402 lines. push ( ` });` );
403 lines. push ( ` if (!res.ok) throw new Error( \`\$ {res.status} \$ {res.statusText}: \$ {await res.text()} \` );` );
404 lines. push ( ` const ct = res.headers.get('content-type') || '';` );
405 lines. push ( ` return ct.includes('json') ? res.json() : res.text();` );
406 lines. push ( `} \n ` );
407
408 // GraphQL / multiplexed operations
409 if (operations. length ) {
410 // Group by parent path + discriminator to emit one dispatcher per GQL endpoint
411 const byParent = new Map ();
412 for ( const op of operations) {
413 const key = op.parentPath || op.path;
414 if ( ! byParent. has (key)) byParent. set (key, []);
415 byParent. get (key). push (op);
416 }
417
418 for ( const [ parentPath , ops ] of byParent) {
419 // Check if it's a persisted-query GraphQL endpoint
420 const isPersisted = ops. some ( op =>
421 op.requestExample?.extensions?.persistedQuery?.sha256Hash);
422
423 if (isPersisted) {
424 // Build a hash lookup table
425 lines. push ( `// Persisted query hashes for ${ parentPath }` );
426 lines. push ( `const HASHES = {` );
427 for ( const op of ops) {
428 const hash = op.requestExample?.extensions?.persistedQuery?.sha256Hash;
429 if (hash) lines. push ( ` ${ op . operationName }: '${ hash }',` );
430 }
431 lines. push ( `}; \n ` );
432 }
433
434 // Emit a function per operation
435 for ( const op of ops) {
436 const fnName = toFnName (op.operationName);
437 const vars = op.requestExample?.variables;
438 const varKeys = vars && typeof vars === 'object' ? Object. keys (vars) : [];
439
440 // Build JSDoc
441 lines. push ( `/**` );
442 if (varKeys. length ) {
443 for ( const k of varKeys) {
444 const v = vars[k];
445 const t = v === null ? '*' : Array. isArray (v) ? 'Array' : typeof v;
446 lines. push ( ` * @param {${ t }} variables.${ k }` );
447 }
448 }
449 lines. push ( ` * @returns {Promise<object>}` );
450 lines. push ( ` */` );
451
452 lines. push ( `export async function ${ fnName }(variables = {}) {` );
453 if (isPersisted) {
454 lines. push ( ` return request('${ parentPath }', {` );
455 lines. push ( ` method: 'POST',` );
456 lines. push ( ` query: { optype: 'query', opname: '${ op . operationName }' },` );
457 lines. push ( ` body: {` );
458 lines. push ( ` operationName: '${ op . operationName }',` );
459 lines. push ( ` variables,` );
460 lines. push ( ` extensions: { persistedQuery: { version: 1, sha256Hash: HASHES.${ op . operationName } } },` );
461 lines. push ( ` },` );
462 lines. push ( ` });` );
463 } else {
464 lines. push ( ` return request('${ parentPath }', {` );
465 lines. push ( ` method: 'POST',` );
466 lines. push ( ` body: { ${ op . discriminatorField || 'operationName'}: '${ op . operationName }', variables },` );
467 lines. push ( ` });` );
468 }
469 lines. push ( `} \n ` );
470 }
471 }
472 }
473
474 // Regular REST endpoints
475 for ( const ep of regular) {
476 const fnName = makeOpId (ep). replace ( / ^ (get | post | put | patch | delete)_/ , ( _ , m ) => m);
477 const hasBody = [ 'POST' , 'PUT' , 'PATCH' ]. includes (ep.method) && ep.requestBodyKnown;
478
479 lines. push ( `export async function ${ fnName }(${ hasBody ? 'body, ' : ''}options = {}) {` );
480 lines. push ( ` return request('${ ep . path }', {` );
481 lines. push ( ` method: '${ ep . method }',` );
482 if (hasBody) lines. push ( ` body,` );
483 lines. push ( ` ...options,` );
484 lines. push ( ` });` );
485 lines. push ( `} \n ` );
486 }
487
488 return lines. join ( ' \n ' ) + ' \n ' ;
489 }
490
491 function buildReport ({ kept , dropped , servers , redaction , minSamples , hasClient }) {
492 const lines = [];
493 const baseUrl = servers[ 0 ]?.url || '' ;
494 lines. push ( '# Discovered API \n ' );
495 lines. push ( `**Base URL:** \` ${ baseUrl || '(unknown)'} \`\n ` );
496
497 // Separate decomposed (named operations) from regular endpoints
498 const operations = kept. filter ( e => e.operationName);
499 const regular = kept. filter ( e => ! e.operationName);
500
501 // Quick-start with generated client
502 if (hasClient) {
503 const allFns = [ ... operations, ... regular];
504 const fnNames = allFns. map ( e => e.operationName ? toFnName (e.operationName) : makeOpId (e));
505 lines. push ( '## Quick start \n ' );
506 lines. push ( '```js' );
507 lines. push ( `import { ${ fnNames . join ( ', ' ) } } from './client.mjs';` );
508 lines. push ( '``` \n ' );
509 lines. push ( `**${ fnNames . length } functions**, zero dependencies. See [ \` client.mjs \` ](./client.mjs) for full signatures. \n ` );
510 }
511
512 // --- Named operations (GraphQL / multiplexed) ---
513 if (operations. length ) {
514 lines. push ( '## Operations \n ' );
515 lines. push ( 'These are logical operations multiplexed over a single endpoint. \n ' );
516
517 const sorted = [ ... operations]. sort (( a , b ) => b.sampleCount - a.sampleCount);
518 for ( const ep of sorted) {
519 lines. push ( `### ${ ep . operationName } \n ` );
520 lines. push ( `- **Endpoint:** \` ${ ep . method } ${ ep . parentPath || ep . path } \` ` );
521 lines. push ( `- **Discriminator:** \` ${ ep . discriminatorField }: "${ ep . operationName }" \` ` );
522 lines. push ( `- **Samples:** ${ ep . sampleCount } | **Statuses:** ${ ep . statusCodes . join ( ', ' ) || '—'}` );
523 lines. push ( '' );
524
525 // Curl example from request body
526 if (ep.requestExample) {
527 const body = JSON . stringify (ep.requestExample, null , 2 );
528 const curlPath = ep.parentPath || ep.path;
529 lines. push ( '```bash' );
530 lines. push ( `curl -X ${ ep . method } '${ baseUrl }${ curlPath }' \\ ` );
531 lines. push ( ` -H 'Content-Type: application/json' \\ ` );
532 lines. push ( ` -d '${ body }'` );
533 lines. push ( '``` \n ' );
534 }
535
536 // Key variables (for GraphQL, show the variables object shape)
537 if (ep.requestExample?.variables && typeof ep.requestExample.variables === 'object' ) {
538 const vars = ep.requestExample.variables;
539 const varKeys = Object. keys (vars);
540 if (varKeys. length ) {
541 lines. push ( '**Variables:** \n ' );
542 lines. push ( '| Name | Example | Type |' );
543 lines. push ( '|---|---|---|' );
544 for ( const k of varKeys) {
545 const v = vars[k];
546 const t = Array. isArray (v) ? 'array' : typeof v;
547 const example = JSON . stringify (v);
548 const truncated = example. length > 60 ? example. slice ( 0 , 57 ) + '...' : example;
549 lines. push ( `| \` ${ k } \` | \` ${ truncated } \` | ${ t } |` );
550 }
551 lines. push ( '' );
552 }
553 }
554
555 // Response shape summary
556 if (ep.responseExample) {
557 const respStr = JSON . stringify (ep.responseExample, null , 2 );
558 const truncResp = respStr. length > 1500 ? respStr. slice ( 0 , 1500 ) + ' \n ... \n }' : respStr;
559 lines. push ( '<details><summary>Example response</summary> \n ' );
560 lines. push ( '```json' );
561 lines. push (truncResp);
562 lines. push ( '``` \n </details> \n ' );
563 }
564 }
565 }
566
567 // --- Regular REST endpoints ---
568 if (regular. length ) {
569 lines. push ( '## Endpoints \n ' );
570 lines. push ( '| Method | Path | Samples | Statuses | Confidence |' );
571 lines. push ( '|---|---|---|---|---|' );
572 const sorted = [ ... regular]. sort (( a , b ) => b.sampleCount - a.sampleCount);
573 for ( const ep of sorted) {
574 lines. push ( `| ${ ep . method } | \` ${ ep . path } \` | ${ ep . sampleCount } | ${ ep . statusCodes . join ( ', ' ) || '—'} | ${ confidenceBucket ( ep ) } |` );
575 }
576 lines. push ( '' );
577
578 // Curl examples for top regular endpoints
579 const withExamples = sorted. filter ( e => e.requestExample || e.responseExample). slice ( 0 , 5 );
580 for ( const ep of withExamples) {
581 lines. push ( `### \` ${ ep . method } ${ ep . path } \`\n ` );
582 if (ep.requestExample) {
583 const body = JSON . stringify (ep.requestExample, null , 2 );
584 lines. push ( '```bash' );
585 lines. push ( `curl -X ${ ep . method } '${ baseUrl }${ ep . path }' \\ ` );
586 lines. push ( ` -H 'Content-Type: application/json' \\ ` );
587 lines. push ( ` -d '${ body }'` );
588 lines. push ( '``` \n ' );
589 }
590 if (ep.responseExample) {
591 const respStr = JSON . stringify (ep.responseExample, null , 2 );
592 const truncResp = respStr. length > 1000 ? respStr. slice ( 0 , 1000 ) + ' \n ... \n }' : respStr;
593 lines. push ( '<details><summary>Example response</summary> \n ' );
594 lines. push ( '```json' );
595 lines. push (truncResp);
596 lines. push ( '``` \n </details> \n ' );
597 }
598 }
599 }
600
601 if ( ! kept. length ) lines. push ( 'No API endpoints discovered. \n ' );
602
603 // --- Coverage ---
604 lines. push ( '## Coverage \n ' );
605 lines. push ( `- **${ kept . length }** API endpoints discovered` );
606 if (dropped. length ) lines. push ( `- **${ dropped . length }** dropped (below --min-samples=${ minSamples })` );
607 const noResp = kept. filter ( e => ! e.responseBodyKnown);
608 if (noResp. length ) lines. push ( `- **${ noResp . length }** missing response-body schemas` );
609 const singleSample = kept. filter ( e => e.sampleCount === 1 );
610 if (singleSample. length ) lines. push ( `- **${ singleSample . length }** observed only once` );
611 lines. push ( '' );
612
613 return lines. join ( ' \n ' ) + ' \n ' ;
614 }
615
616 // ---------------------------------------------------------------------------
617 // HTML report
618 // ---------------------------------------------------------------------------
619
620 function escHtml ( s ) {
621 return String (s). replace ( /&/ g , '&' ). replace ( /</ g , '<' ). replace ( />/ g , '>' ). replace ( /"/ g , '"' );
622 }
623
624 function buildHtmlReport ({ kept , servers , title , clientCode }) {
625 const baseUrl = servers[ 0 ]?.url || '' ;
626 const operations = kept. filter ( e => e.operationName);
627 const regular = kept. filter ( e => ! e.operationName);
628 const all = [ ... operations. sort (( a , b ) => b.sampleCount - a.sampleCount), ... regular];
629
630 const opCards = all. map (( ep , i ) => {
631 const name = ep.operationName || `${ ep . method } ${ ep . path }` ;
632 const fnName = ep.operationName ? toFnName (ep.operationName) : null ;
633 const vars = ep.requestExample?.variables;
634 const varRows = vars && typeof vars === 'object'
635 ? Object. entries (vars). map (([ k , v ]) => {
636 const t = v === null ? 'null' : Array. isArray (v) ? 'array' : typeof v;
637 const ex = JSON . stringify (v);
638 return `<tr><td><code>${ escHtml ( k ) }</code></td><td>${ escHtml ( t ) }</td><td><code>${ escHtml ( ex . length > 50 ? ex . slice ( 0 , 47 ) + '...' : ex ) }</code></td></tr>` ;
639 }). join ( ' \n ' )
640 : '' ;
641
642 const reqBody = ep.requestExample ? JSON . stringify (ep.requestExample, null , 2 ) : null ;
643 const respBody = ep.responseExample ? JSON . stringify (ep.responseExample, null , 2 ) : null ;
644 const truncResp = respBody && respBody. length > 2000 ? respBody. slice ( 0 , 2000 ) + ' \n ...' : respBody;
645
646 return `
647 <div class="card" id="op-${ i }">
648 <div class="card-header" onclick="this.parentElement.classList.toggle('open')">
649 <div class="card-title">
650 <span class="method">POST</span>
651 <span class="op-name">${ escHtml ( name ) }</span>
652 </div>
653 <div class="card-meta">
654 <span class="badge">${ ep . sampleCount } sample${ ep . sampleCount !== 1 ? 's' : ''}</span>
655 ${ fnName ? `<code class="fn-name">${ escHtml ( fnName ) }()</code>` : ''}
656 </div>
657 </div>
658 <div class="card-body">
659 ${ ep . parentPath ? `<p class="endpoint-line"><strong>Endpoint:</strong> <code>${ escHtml ( ep . method ) } ${ escHtml ( baseUrl ) }${ escHtml ( ep . parentPath ) }</code></p>` : ''}
660 ${ ep . discriminatorField ? `<p class="endpoint-line"><strong>Discriminator:</strong> <code>${ escHtml ( ep . discriminatorField ) }: "${ escHtml ( ep . operationName ) }"</code></p>` : ''}
661
662 ${ varRows ? `
663 <h4>Variables</h4>
664 <table class="var-table">
665 <thead><tr><th>Name</th><th>Type</th><th>Example</th></tr></thead>
666 <tbody>${ varRows }</tbody>
667 </table>` : ''}
668
669 ${ fnName ? `
670 <h4>Client usage</h4>
671 <pre><code>import { ${ escHtml ( fnName ) } } from './client.mjs';
672
673 const result = await ${ escHtml ( fnName ) }(${ vars ? JSON . stringify ( Object . fromEntries ( Object . entries ( vars ). filter (([, v ]) => v !== '<redacted>' ). slice ( 0 , 4 ). map (([ k , v ]) => {
674 if (Array. isArray (v) && v. length > 2 ) return [k, v. slice ( 0 , 2 )];
675 return [k, v];
676 } )), null , 2 ) : '{}'});</code></pre>` : ''}
677
678 ${ reqBody ? `
679 <h4>Request body</h4>
680 <pre class="scrollable"><code>${ escHtml ( reqBody ) }</code></pre>` : ''}
681
682 ${ truncResp ? `
683 <h4>Response</h4>
684 <pre class="scrollable"><code>${ escHtml ( truncResp ) }</code></pre>` : ''}
685 </div>
686 </div>` ;
687 }). join ( ' \n ' );
688
689 return `<!DOCTYPE html>
690 <html lang="en">
691 <head>
692 <meta charset="UTF-8">
693 <meta name="viewport" content="width=device-width, initial-scale=1.0">
694 <title>${ escHtml ( title ) } — API Report</title>
695 <style>
696 :root {
697 --brand: #F03603;
698 --black: #100D0D;
699 --gray: #514F4F;
700 --border: #edebeb;
701 --bg: #F9F6F4;
702 --card: #ffffff;
703 --text: #100D0D;
704 --muted: #514F4F;
705 --green: #22863a;
706 --code-bg: #f6f5f5;
707 }
708 * { margin: 0; padding: 0; box-sizing: border-box; }
709 body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; font-size: 15px; }
710 .container { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; }
711
712 header { margin-bottom: 2rem; }
713 header h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.25rem; }
714 header .meta { color: var(--muted); font-size: 0.875rem; }
715
716 .summary { display: flex; gap: 0.75rem; margin-bottom: 2rem; flex-wrap: wrap; }
717 .stat { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 1rem 1.25rem; flex: 1; min-width: 120px; }
718 .stat .label { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; margin-bottom: 0.25rem; }
719 .stat .value { font-size: 1.5rem; font-weight: 700; color: var(--black); }
720
721 .card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 0.5rem; overflow: hidden; }
722 .card-header { padding: 0.875rem 1.25rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none; }
723 .card-header:hover { background: #faf9f8; }
724 .card-title { display: flex; align-items: center; gap: 0.75rem; }
725 .card-meta { display: flex; align-items: center; gap: 0.75rem; }
726 .method { background: var(--green); color: white; font-size: 0.6875rem; font-weight: 700; padding: 0.2rem 0.5rem; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.03em; }
727 .op-name { font-weight: 600; font-size: 0.9375rem; }
728 .fn-name { font-size: 0.8125rem; color: var(--muted); background: var(--code-bg); padding: 0.15rem 0.4rem; border-radius: 3px; }
729 .badge { font-size: 0.75rem; color: var(--muted); background: var(--code-bg); padding: 0.15rem 0.5rem; border-radius: 10px; }
730
731 .card-body { display: none; padding: 0 1.25rem 1.25rem; border-top: 1px solid var(--border); }
732 .card.open .card-body { display: block; padding-top: 1rem; }
733 .card-body h4 { font-size: 0.8125rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin: 1.25rem 0 0.5rem; }
734 .card-body h4:first-child { margin-top: 0; }
735 .endpoint-line { font-size: 0.875rem; margin-bottom: 0.25rem; }
736
737 .var-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
738 .var-table th { text-align: left; font-weight: 600; color: var(--muted); padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; }
739 .var-table td { padding: 0.35rem 0.75rem; border-bottom: 1px solid #f5f4f3; }
740 .var-table code { font-size: 0.8125rem; }
741
742 pre { background: var(--code-bg); border-radius: 4px; padding: 0.75rem 1rem; overflow-x: auto; font-size: 0.8125rem; line-height: 1.5; }
743 pre.scrollable { max-height: 400px; overflow-y: auto; }
744 code { font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace; font-size: 0.875em; }
745
746 .client-section { margin-top: 2rem; }
747 .client-section h2 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.75rem; }
748 </style>
749 </head>
750 <body>
751 <div class="container">
752 <header>
753 <h1>${ escHtml ( title ) }</h1>
754 <p class="meta">${ escHtml ( baseUrl ) } · ${ all . length } operation${ all . length !== 1 ? 's' : ''} discovered from browser trace</p>
755 </header>
756
757 <div class="summary">
758 <div class="stat"><div class="label">Operations</div><div class="value">${ all . length }</div></div>
759 <div class="stat"><div class="label">Endpoint</div><div class="value" style="font-size:0.875rem">${ escHtml ( operations [ 0 ]?. parentPath || regular [ 0 ]?. path || '—' ) }</div></div>
760 <div class="stat"><div class="label">Protocol</div><div class="value" style="font-size:0.875rem">${ operations . length ? 'GraphQL (APQ)' : 'REST'}</div></div>
761 <div class="stat"><div class="label">Total samples</div><div class="value">${ all . reduce (( s , e ) => s + e . sampleCount , 0 ) }</div></div>
762 </div>
763
764 ${ opCards }
765
766 ${ clientCode ? `
767 <div class="client-section">
768 <h2>Generated client</h2>
769 <p style="color:var(--muted);font-size:0.875rem;margin-bottom:0.75rem;">Copy <code>client.mjs</code> into your project. Zero dependencies — uses native <code>fetch</code>.</p>
770 <pre class="scrollable"><code>${ escHtml ( clientCode ) }</code></pre>
771 </div>` : ''}
772 </div>
773 </body>
774 </html>
775 ` ;
776 }
777
778 if ( import . meta .url === `file://${ process . argv [ 1 ] }` ) {
779 const out = process.argv[ 2 ];
780 if ( ! out) { console. error ( 'usage: emit.mjs <out-dir>' ); process. exit ( 2 ); }
781 const stats = emit (out);
782 console. log ( `emit: ${ stats . endpoints } endpoints, ${ stats . servers } server(s), ${ stats . components } components${ stats . droppedLowSample ? `, ${ stats . droppedLowSample } dropped (low sample)` : ''}` );
783 }