@bettercms-ai/preview-runtime 0.2.0 → 0.4.0

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/cli.js CHANGED
@@ -36,6 +36,25 @@ function readJson(file) {
36
36
  fail(`could not read ${file}: ${error.message}`);
37
37
  }
38
38
  }
39
+ var LANDMARKS = /* @__PURE__ */ new Set(["header", "footer", "nav"]);
40
+ var isStringRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((v) => typeof v === "string");
41
+ function pageSource(id, source) {
42
+ const { route, blockId, groupKey, layoutSectionId, landmark } = source;
43
+ if (typeof route !== "string" || !route.startsWith("/") || route.startsWith("//") || /[\s?#\\]/.test(route) || route.split("/").includes("..")) {
44
+ fail(`component ${id} has an unsafe page route: ${String(route)}`);
45
+ }
46
+ if (typeof blockId === "string" && blockId) {
47
+ if (groupKey != null && typeof groupKey !== "string") fail(`component ${id}: groupKey must be a string`);
48
+ if (source.source != null && !isStringRecord(source.source)) fail(`component ${id}: source must map prop keys to page paths`);
49
+ return { kind: "page", route, blockId, groupKey: groupKey ?? null, source: source.source ?? null };
50
+ }
51
+ if (typeof layoutSectionId === "string" && layoutSectionId) {
52
+ if (landmark != null && !LANDMARKS.has(landmark)) fail(`component ${id}: unknown landmark ${String(landmark)}`);
53
+ if (source.bindings != null && !isStringRecord(source.bindings)) fail(`component ${id}: bindings must map input ids to layout field ids`);
54
+ return { kind: "page", route, layoutSectionId, landmark: landmark ?? null, bindings: source.bindings ?? {} };
55
+ }
56
+ fail(`component ${id}: a page source names neither a placement (blockId) nor a layout section (layoutSectionId)`);
57
+ }
39
58
  function validateManifest(root, manifest) {
40
59
  if (!manifest || !Array.isArray(manifest.components) || manifest.components.length === 0) {
41
60
  fail("the manifest lists no components");
@@ -45,6 +64,7 @@ function validateManifest(root, manifest) {
45
64
  if (!entry || typeof entry.id !== "string" || !entry.id.trim()) fail("a manifest entry has no id");
46
65
  if (seen.has(entry.id)) fail(`component ${entry.id} is listed twice`);
47
66
  seen.add(entry.id);
67
+ if (entry.source?.kind === "page") return { id: entry.id, source: pageSource(entry.id, entry.source) };
48
68
  const path = entry.source?.path;
49
69
  if (typeof path !== "string" || path.startsWith("/") || path.includes("\\") || path.split("/").includes("..")) {
50
70
  fail(`component ${entry.id} has an unsafe source path: ${String(path)}`);
@@ -80,14 +100,20 @@ function registrySource(fromDir, root, entries) {
80
100
  const imports = [];
81
101
  const keys = [];
82
102
  const kinds = [];
103
+ const pages = [];
83
104
  entries.forEach((entry, index) => {
84
- let specifier = relative(fromDir, join(root, entry.source.path)).split(sep).join("/");
105
+ const source = entry.source;
106
+ kinds.push(` ${JSON.stringify(entry.id)}: ${JSON.stringify(source.kind ?? "file")},`);
107
+ if (source.kind === "page") {
108
+ pages.push(` ${JSON.stringify(entry.id)}: ${JSON.stringify(source)},`);
109
+ return;
110
+ }
111
+ let specifier = relative(fromDir, join(root, source.path)).split(sep).join("/");
85
112
  if (!specifier.startsWith(".")) specifier = `./${specifier}`;
86
113
  if ([".tsx", ".ts", ".jsx", ".js"].includes(extname(specifier))) specifier = specifier.slice(0, -extname(specifier).length);
87
114
  const local = `Component${index}`;
88
- imports.push(entry.source.export === "default" ? `import ${local} from ${JSON.stringify(specifier)};` : `import { ${entry.source.export} as ${local} } from ${JSON.stringify(specifier)};`);
115
+ imports.push(source.export === void 0 || source.export === "default" ? `import ${local} from ${JSON.stringify(specifier)};` : `import { ${source.export} as ${local} } from ${JSON.stringify(specifier)};`);
89
116
  keys.push(` ${JSON.stringify(entry.id)}: ${local},`);
90
- kinds.push(` ${JSON.stringify(entry.id)}: ${JSON.stringify(entry.source.kind ?? "file")},`);
91
117
  });
92
118
  return `${imports.join("\n")}
93
119
 
@@ -95,11 +121,16 @@ export const registry: Record<string, any> = {
95
121
  ${keys.join("\n")}
96
122
  };
97
123
 
98
- export const kinds: Record<string, "file" | "section"> = {
124
+ export const kinds: Record<string, "file" | "section" | "page"> = {
99
125
  ${kinds.join("\n")}
100
126
  };
101
127
 
102
- export const has = (componentId: string): boolean => Object.prototype.hasOwnProperty.call(registry, componentId);
128
+ export const pages: Record<string, any> = {
129
+ ${pages.join("\n")}
130
+ };
131
+
132
+ const own = (map: object, componentId: string) => Object.prototype.hasOwnProperty.call(map, componentId);
133
+ export const has = (componentId: string): boolean => own(registry, componentId) || own(pages, componentId);
103
134
  `;
104
135
  }
105
136
  function writeRuntimeLibrary(dir) {
@@ -107,6 +138,8 @@ function writeRuntimeLibrary(dir) {
107
138
  const types = join(here, "server.d.ts");
108
139
  if (existsSync(types)) writeFileSync(join(dir, "server.d.mts"), readFileSync(types, "utf8"));
109
140
  writeFileSync(join(dir, "shell.ts"), `export default ${JSON.stringify(readFileSync(join(here, "shell.global.js"), "utf8"))};
141
+ `);
142
+ writeFileSync(join(dir, "scope.ts"), `export default ${JSON.stringify(readFileSync(join(here, "scope-client.global.js"), "utf8"))};
110
143
  `);
111
144
  }
112
145
  function astroGlobalStyles(root) {
@@ -136,7 +169,8 @@ function ensureAstroNodeAdapter(root) {
136
169
  } catch {
137
170
  }
138
171
  const astroVersion = readJson(require2.resolve("astro/package.json")).version;
139
- const range = ASTRO_NODE_ADAPTER[astroVersion.split(".")[0]];
172
+ const [major, minor] = astroVersion.split(".").map(Number);
173
+ const range = major === 7 && minor < 3 ? ">=11.0.0 <11.1.3" : ASTRO_NODE_ADAPTER[String(major)];
140
174
  if (!range) fail(`Astro ${astroVersion} is not supported for component previews yet`);
141
175
  run(root, "npm", ["install", "--no-save", "--no-audit", "--no-fund", `@astrojs/node@${range}`]);
142
176
  }
@@ -168,11 +202,13 @@ ${handler("GET", "() => handleHealth()")}`);
168
202
  const styles = astroGlobalStyles(root).map((file) => `import ${JSON.stringify(relative(gen, file).split(sep).join("/"))};`).join("\n");
169
203
  writeFileSync(join(gen, "render.astro"), `---
170
204
  ${styles}
171
- import { renderEntry, renderHeaders } from "./server.mjs";
172
- import { registry, kinds } from "./registry";
205
+ import { renderEntry, renderHeaders, renderPageSection } from "./server.mjs";
206
+ import { registry, kinds, pages } from "./registry";
207
+ import scope from "./scope";
173
208
  export const prerender = false;
174
209
  const headers = renderHeaders();
175
210
  const entry = renderEntry(Astro.url.searchParams.get("id"));
211
+ if (entry && pages[entry.componentId]) return await renderPageSection(Astro.request, entry, pages[entry.componentId], scope);
176
212
  const Component = entry ? registry[entry.componentId] : undefined;
177
213
  const section = entry ? kinds[entry.componentId] === "section" : false;
178
214
  for (const [name, value] of Object.entries(headers)) Astro.response.headers.set(name, value);
@@ -266,17 +302,30 @@ export function POST(request: Request) {
266
302
  export function GET() {
267
303
  return handleHealth();
268
304
  }
305
+ `);
306
+ route("render-page", `import { renderEntry, renderPageSection } from "../server.mjs";
307
+ import { pages } from "../registry";
308
+ import scope from "../scope";
309
+ export function GET(request: Request) {
310
+ const entry = renderEntry(new URL(request.url).searchParams.get("id"));
311
+ const page = entry ? pages[entry.componentId] : undefined;
312
+ if (!entry || !page) return new Response("Not found", { status: 404 });
313
+ return renderPageSection(request, entry, page, scope);
314
+ }
269
315
  `);
270
316
  mkdirSync(join(gen, "render"), { recursive: true });
271
- writeFileSync(join(gen, "render", "page.tsx"), `import { notFound } from "next/navigation";
317
+ writeFileSync(join(gen, "render", "page.tsx"), `import { notFound, redirect } from "next/navigation";
272
318
  import { renderEntry } from "../server.mjs";
273
- import { registry, kinds } from "../registry";
319
+ import { registry, kinds, pages } from "../registry";
274
320
 
275
321
  export const dynamic = "force-dynamic";
276
322
 
277
323
  export default async function BetterCMSComponentPreview({ searchParams }: { searchParams: Promise<{ id?: string }> }) {
278
324
  const { id } = await searchParams;
279
325
  const entry = renderEntry(id);
326
+ // A page-kind component answers with a whole document, which a page inside the root layout cannot be.
327
+ // Relative on purpose: Next prefixes basePath onto a "/"-rooted redirect, and the browser resolves this one.
328
+ if (entry && pages[entry.componentId]) redirect(\`render-page?id=\${encodeURIComponent(id!)}\`);
280
329
  const Component = entry ? registry[entry.componentId] : undefined;
281
330
  if (!entry || !Component) notFound();
282
331
  // The marker the runtime page checks before acknowledging: a 404 or an error page never carries it.
@@ -363,6 +412,28 @@ import { tmpdir } from "os";
363
412
  import { dirname as dirname2, join as join2 } from "path";
364
413
  var PREVIEW_ROUTE_BASE = "/__bettercms/component-preview/__bcms";
365
414
  var RUNTIME_PATH = `${PREVIEW_ROUTE_BASE}/runtime`;
415
+ var API_TIMEOUT_MS = 6e4;
416
+ var LOCAL_FETCH_TIMEOUT_MS = 3e4;
417
+ var HEALTH_PROBE_TIMEOUT_MS = 5e3;
418
+ var RENDER_TIMEOUT_PER_VIEWPORT_MS = 9e4;
419
+ var BUNDLE_UPLOAD_TIMEOUT_MS = 5 * 6e4;
420
+ var DEFAULT_BATCH_BUDGET_MS = 15 * 6e4;
421
+ async function bounded(what, ms, run2) {
422
+ const controller = new AbortController();
423
+ let timer;
424
+ const expired = new Promise((_, reject) => {
425
+ timer = setTimeout(() => {
426
+ controller.abort();
427
+ const limit = ms >= 1e3 ? `${Math.round(ms / 1e3)} seconds` : `${ms} ms`;
428
+ reject(new ValidationFailure("COMPONENT_VALIDATION_STEP_TIMEOUT", `${what} did not finish within ${limit}.`));
429
+ }, ms);
430
+ });
431
+ try {
432
+ return await Promise.race([run2(controller.signal), expired]);
433
+ } finally {
434
+ clearTimeout(timer);
435
+ }
436
+ }
366
437
  var ValidationFailure = class extends Error {
367
438
  constructor(code, message) {
368
439
  super(message);
@@ -371,11 +442,49 @@ var ValidationFailure = class extends Error {
371
442
  }
372
443
  code;
373
444
  };
374
- function required(name) {
375
- const value = process.env[name]?.trim();
445
+ function required(name, env = process.env) {
446
+ const value = env[name]?.trim();
376
447
  if (!value) throw new CliFailure(`${name} is not set. This command runs inside the BetterCMS component validation workflow.`);
377
448
  return value;
378
449
  }
450
+ var isViewport = (value) => !!value && typeof value === "object" && typeof value.name === "string" && Number.isFinite(value.width) && Number.isFinite(value.height);
451
+ var isBatchItem = (value) => {
452
+ if (!value || typeof value !== "object") return false;
453
+ const item = value;
454
+ return ["requestId", "componentId", "familyKey"].every((key) => typeof item[key] === "string" && item[key].trim() !== "") && Array.isArray(item.nativeViewports) && item.nativeViewports.length > 0 && item.nativeViewports.every(isViewport);
455
+ };
456
+ var invalidBatch = (detail) => new CliFailure(`COMPONENT_VALIDATION_BATCH_INVALID: ${detail}`);
457
+ function resolveBatch(env = process.env) {
458
+ const raw = env.BCMS_BATCH?.trim();
459
+ if (raw && raw !== "null") {
460
+ let parsed;
461
+ try {
462
+ parsed = JSON.parse(raw);
463
+ } catch {
464
+ throw invalidBatch("BCMS_BATCH is not valid JSON.");
465
+ }
466
+ if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isBatchItem)) {
467
+ throw invalidBatch("BCMS_BATCH must be a non-empty array of { requestId, componentId, familyKey, nativeViewports }.");
468
+ }
469
+ if (new Set(parsed.map((item2) => item2.requestId)).size !== parsed.length) {
470
+ throw invalidBatch("BCMS_BATCH names the same request twice.");
471
+ }
472
+ return parsed;
473
+ }
474
+ const requestId = required("BCMS_REQUEST_ID", env);
475
+ const componentId = required("BCMS_COMPONENT_ID", env);
476
+ const familyKey = required("BCMS_FAMILY_KEY", env);
477
+ let nativeViewports;
478
+ try {
479
+ nativeViewports = JSON.parse(required("BCMS_NATIVE_VIEWPORTS", env));
480
+ } catch (error) {
481
+ if (error instanceof CliFailure) throw error;
482
+ throw invalidBatch("BCMS_NATIVE_VIEWPORTS is not valid JSON.");
483
+ }
484
+ const item = { requestId, componentId, familyKey, nativeViewports };
485
+ if (!isBatchItem(item)) throw invalidBatch("BCMS_NATIVE_VIEWPORTS must be a non-empty array of { name, width, height }.");
486
+ return [item];
487
+ }
379
488
  var sha256 = (data) => createHash("sha256").update(data).digest("hex");
380
489
  function canonical(value) {
381
490
  if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
@@ -384,6 +493,191 @@ function canonical(value) {
384
493
  }
385
494
  return JSON.stringify(value);
386
495
  }
496
+ var toCliFailure = (error) => new CliFailure(`${error instanceof ValidationFailure ? error.code : "COMPONENT_VALIDATION_FAILED"}: ${error.message.slice(0, 2e3)}`);
497
+ async function runValidationBatch(batch, ctx, deps) {
498
+ const now = deps.now ?? Date.now;
499
+ const deadline = now() + (deps.budgetMs ?? DEFAULT_BATCH_BUDGET_MS);
500
+ const propsMs = deps.timeouts?.propsMs ?? LOCAL_FETCH_TIMEOUT_MS;
501
+ const renderPerViewportMs = deps.timeouts?.renderPerViewportMs ?? RENDER_TIMEOUT_PER_VIEWPORT_MS;
502
+ const apiMs = deps.timeouts?.apiMs ?? API_TIMEOUT_MS;
503
+ const seconds = (ms) => `${Math.ceil(Math.max(ms, 0) / 1e3)}s`;
504
+ const log = deps.log ?? ((line) => console.log(line));
505
+ const logError = deps.error ?? ((line) => console.error(line));
506
+ const requestPath = (item) => `/api/v1/projects/${ctx.projectId}/component-implementation/implementation-requests/${item.requestId}`;
507
+ const completed = [];
508
+ const failed = [];
509
+ const claim = (item) => deps.api(`${requestPath(item)}/claim`, {
510
+ method: "POST",
511
+ body: {
512
+ componentId: item.componentId,
513
+ commitSha: ctx.commitSha,
514
+ adapter: {
515
+ protocol: "bcms-component-runtime-v1",
516
+ kind: "project-route",
517
+ path: RUNTIME_PATH,
518
+ familyKey: item.familyKey,
519
+ previewOrigin: ctx.previewOrigin,
520
+ nativeViewports: item.nativeViewports
521
+ },
522
+ providerRunId: ctx.run.providerRunId,
523
+ providerRunAttempt: ctx.run.providerRunAttempt,
524
+ providerRunUrl: ctx.run.providerRunUrl,
525
+ workflowRef: ctx.run.workflowRef,
526
+ // The server caps this at the request's own expiry and at ten minutes.
527
+ credentialExpiresAt: new Date(Date.now() + 10 * 6e4 - 5e3).toISOString()
528
+ }
529
+ });
530
+ const report = async (item, held, error) => {
531
+ const code = error instanceof ValidationFailure ? error.code : "COMPONENT_VALIDATION_FAILED";
532
+ const message = error.message.slice(0, 2e3);
533
+ failed.push(item.componentId);
534
+ logError(`bcms-preview: ${code}: ${message} (component ${item.componentId})`);
535
+ const reported = held ?? await claim(item).catch((claimError) => {
536
+ logError(`bcms-preview: could not claim request ${item.requestId} to report the failure: ${claimError.message}`);
537
+ return null;
538
+ });
539
+ if (!reported) return;
540
+ await deps.api(`${requestPath(item)}/fail`, {
541
+ method: "POST",
542
+ claim: reported.claimCapability,
543
+ body: { componentId: item.componentId, errorCode: code, errorMessage: message }
544
+ }).catch((reportError) => logError(`bcms-preview: could not report the failure: ${reportError.message}`));
545
+ };
546
+ let manifest;
547
+ try {
548
+ manifest = await deps.api(`/api/v1/projects/${ctx.projectId}/component-preview/manifest?kinds=page`);
549
+ } catch (error) {
550
+ for (const item of batch) await report(item, null, error);
551
+ throw toCliFailure(error);
552
+ }
553
+ const hintFor = (componentId) => {
554
+ const entry = manifest.components.find((c) => c.id === componentId);
555
+ return entry?.fallback && entry.source.kind !== "page" ? [`Clear the recorded source: this component renders from page ${entry.fallback.route} without one.`] : [];
556
+ };
557
+ const pending = [];
558
+ for (const item of batch) {
559
+ if (manifest.components.some((c) => c.id === item.componentId)) {
560
+ pending.push(item);
561
+ } else {
562
+ await report(item, null, new ValidationFailure(
563
+ "COMPONENT_SOURCE_NOT_RECORDED",
564
+ "No file is recorded and this component is neither placed on a page nor used in the layout, so there is nothing to render. Place it, add it to the layout, or record its file."
565
+ ));
566
+ }
567
+ }
568
+ if (pending.length === 0) return { completed, failed, exitCode: 1 };
569
+ let runtime = null;
570
+ try {
571
+ runtime = await deps.prepare(manifest);
572
+ const upload = await deps.api(`/api/v1/projects/${ctx.projectId}/artifacts/upload-url`, { method: "POST" });
573
+ const put = await deps.putBundle(upload.uploadUrl, runtime.bundle);
574
+ if (!put.ok) {
575
+ throw new ValidationFailure("COMPONENT_PREVIEW_BUNDLE_UPLOAD_FAILED", `Storage refused the preview bundle (HTTP ${put.status}).`);
576
+ }
577
+ await deps.api(`/api/v1/projects/${ctx.projectId}/component-preview/bundles`, {
578
+ method: "POST",
579
+ body: { commitSha: ctx.commitSha, uploadKey: upload.uploadKey, checksum: `sha256:${sha256(runtime.bundle)}`, sizeBytes: runtime.bundle.byteLength }
580
+ });
581
+ } catch (error) {
582
+ await runtime?.close().catch(() => {
583
+ });
584
+ for (const item of pending) {
585
+ const hint = error instanceof ValidationFailure && error.code === "COMPONENT_PREVIEW_BUILD_FAILED" ? hintFor(item.componentId) : [];
586
+ await report(item, null, hint.length ? new ValidationFailure(error.code, [error.message, ...hint].join(" ")) : error);
587
+ }
588
+ throw toCliFailure(error);
589
+ }
590
+ const ready = runtime;
591
+ try {
592
+ for (const item of pending) {
593
+ const worstCase = propsMs + renderPerViewportMs * item.nativeViewports.length + 2 * apiMs;
594
+ const left = deadline - now();
595
+ if (left < worstCase) {
596
+ await report(item, null, new ValidationFailure(
597
+ "COMPONENT_VALIDATION_BATCH_TIMEOUT",
598
+ `The validation batch did not have enough time left for this component (needs up to ${seconds(worstCase)}, ${seconds(left)} left). Request validation again.`
599
+ ));
600
+ continue;
601
+ }
602
+ const entry = manifest.components.find((c) => c.id === item.componentId);
603
+ const hint = hintFor(item.componentId);
604
+ let held = null;
605
+ try {
606
+ const mark = ready.serverErrorCount();
607
+ const renderId = await bounded(
608
+ `The preview runtime's props endpoint for ${item.componentId}`,
609
+ propsMs,
610
+ (signal) => ready.storeProps(item.componentId, entry.defaultProps, signal)
611
+ );
612
+ const render = await bounded(
613
+ `Rendering ${item.componentId}`,
614
+ renderPerViewportMs * item.nativeViewports.length,
615
+ (signal) => ready.render(
616
+ renderId,
617
+ item.nativeViewports,
618
+ manifest.brandTokenNames,
619
+ // A page-kind render lifts a section too, and stamps its root.
620
+ { section: ready.kinds[item.componentId] !== "file" },
621
+ signal
622
+ )
623
+ );
624
+ const serverErrors = ready.serverErrorsSince(mark).slice(0, 5);
625
+ held = await claim(item);
626
+ const target = held.request;
627
+ const checks = {
628
+ brandKit: {
629
+ status: render.missingTokens.length === 0 ? "passed" : "failed",
630
+ contractHash: target.brandContractHash,
631
+ missingTokens: render.missingTokens
632
+ },
633
+ runtime: {
634
+ status: render.runtimeErrors === 0 && render.consoleErrors === 0 ? "passed" : "failed",
635
+ runtimeErrors: render.runtimeErrors,
636
+ consoleErrors: render.consoleErrors,
637
+ // Only on a failure, and bounded: the server's own error lines first (the real cause), then what the
638
+ // browser saw. The database stores them and the panel shows the first one.
639
+ ...render.runtimeErrors + render.consoleErrors > 0 ? { problems: [...hint, ...serverErrors.map((line) => `server: ${line}`), ...render.problems].slice(0, 10).map((line) => line.slice(0, 200)) } : {}
640
+ },
641
+ visual: { status: "baseline-missing", reviewRequired: true, viewports: render.results }
642
+ };
643
+ const evidenceDigest = `sha256:${sha256(canonical({ requestId: item.requestId, commitSha: target.commitSha, checks }))}`;
644
+ await deps.api(`${requestPath(item)}/complete`, {
645
+ method: "POST",
646
+ claim: held.claimCapability,
647
+ body: {
648
+ componentId: target.componentId,
649
+ candidateId: target.candidateId,
650
+ componentVersion: target.componentVersion,
651
+ familyKey: target.familyKey,
652
+ familyContractHash: target.familyContractHash,
653
+ schemaHash: target.schemaHash,
654
+ brandContractHash: target.brandContractHash,
655
+ dependenciesHash: target.dependenciesHash,
656
+ commitSha: target.commitSha,
657
+ adapterHash: target.adapterHash,
658
+ familyManifestHash: target.familyManifestHash,
659
+ nativeViewports: target.nativeViewports,
660
+ checks,
661
+ evidenceDigest
662
+ }
663
+ });
664
+ completed.push(item.componentId);
665
+ const passed = checks.brandKit.status === "passed" && checks.runtime.status === "passed";
666
+ log(`bcms-preview: validation ${passed ? "PASSED" : "FAILED"} for ${item.componentId}`);
667
+ if (!passed) {
668
+ if (render.missingTokens.length) log(` brand tokens used but not defined: ${render.missingTokens.join(", ")}`);
669
+ for (const problem of [...hint, ...render.problems]) log(` ${problem}`);
670
+ }
671
+ } catch (error) {
672
+ await report(item, held, error);
673
+ }
674
+ }
675
+ } finally {
676
+ await ready.close().catch(() => {
677
+ });
678
+ }
679
+ return { completed, failed, exitCode: failed.length > 0 ? 1 : 0 };
680
+ }
387
681
  function freePort() {
388
682
  return new Promise((resolve2, reject) => {
389
683
  const server = createServer();
@@ -399,7 +693,7 @@ async function waitForOk(url, timeoutMs) {
399
693
  const deadline = Date.now() + timeoutMs;
400
694
  while (Date.now() < deadline) {
401
695
  try {
402
- if ((await fetch(url)).ok) return true;
696
+ if ((await fetch(url, { signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS) })).ok) return true;
403
697
  } catch {
404
698
  }
405
699
  await new Promise((r) => setTimeout(r, 500));
@@ -416,18 +710,22 @@ function ensureBrowser() {
416
710
  throw new ValidationFailure("COMPONENT_VALIDATION_BROWSER_UNAVAILABLE", "A headless browser could not be installed on this runner.");
417
711
  }
418
712
  }
419
- async function renderInBrowser(url, viewports, tokenNames, options) {
420
- ensureBrowser();
421
- const { chromium } = await import("playwright");
422
- const browser = await chromium.launch();
423
- try {
424
- let consoleErrors = 0;
425
- let runtimeErrors = 0;
426
- const missingTokens = /* @__PURE__ */ new Set();
427
- const results = [];
428
- const problems = [];
429
- for (const viewport of viewports) {
430
- const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height } });
713
+ async function renderInBrowser(browser, url, viewports, tokenNames, options, signal) {
714
+ let consoleErrors = 0;
715
+ let runtimeErrors = 0;
716
+ const missingTokens = /* @__PURE__ */ new Set();
717
+ const results = [];
718
+ const problems = [];
719
+ for (const viewport of viewports) {
720
+ if (signal?.aborted) break;
721
+ const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height } });
722
+ const onAbort = () => {
723
+ page.close().catch(() => {
724
+ });
725
+ };
726
+ signal?.addEventListener("abort", onAbort, { once: true });
727
+ if (signal?.aborted) onAbort();
728
+ try {
431
729
  page.on("console", (message) => {
432
730
  if (message.type() === "error") {
433
731
  consoleErrors += 1;
@@ -475,34 +773,51 @@ async function renderInBrowser(url, viewports, tokenNames, options) {
475
773
  status: "baseline-missing",
476
774
  candidateDigest: `sha256:${sha256(png)}`
477
775
  });
478
- await page.close();
776
+ } finally {
777
+ signal?.removeEventListener("abort", onAbort);
778
+ await page.close().catch(() => {
779
+ });
479
780
  }
480
- return { results, consoleErrors, runtimeErrors, missingTokens: [...missingTokens].sort(), problems };
481
- } finally {
482
- await browser.close();
483
781
  }
782
+ return { results, consoleErrors, runtimeErrors, missingTokens: [...missingTokens].sort(), problems };
484
783
  }
485
784
  async function validateComponent() {
486
785
  const apiUrl = required("BCMS_API_URL");
487
786
  const apiKey = required("BCMS_API_KEY");
488
- const projectId = required("BCMS_PROJECT_ID");
489
- const requestId = required("BCMS_REQUEST_ID");
490
- const componentId = required("BCMS_COMPONENT_ID");
491
- const commitSha = required("BCMS_COMMIT_SHA");
492
- const familyKey = required("BCMS_FAMILY_KEY");
493
- const previewOrigin = required("BCMS_PREVIEW_ORIGIN");
494
- const nativeViewports = JSON.parse(required("BCMS_NATIVE_VIEWPORTS"));
787
+ const batch = resolveBatch();
788
+ const ctx = {
789
+ projectId: required("BCMS_PROJECT_ID"),
790
+ commitSha: required("BCMS_COMMIT_SHA"),
791
+ previewOrigin: required("BCMS_PREVIEW_ORIGIN"),
792
+ run: {
793
+ providerRunId: required("BCMS_RUN_ID"),
794
+ providerRunAttempt: Number(process.env.BCMS_RUN_ATTEMPT) || 1,
795
+ providerRunUrl: required("BCMS_RUN_URL"),
796
+ workflowRef: required("BCMS_WORKFLOW_REF")
797
+ }
798
+ };
495
799
  const api = async (path, init = {}) => {
496
- const response = await fetch(new URL(path, apiUrl), {
497
- method: init.method ?? "GET",
498
- headers: {
499
- authorization: `Bearer ${apiKey}`,
500
- "content-type": "application/json",
501
- ...init.claim ? { "x-bcms-component-claim": init.claim } : {}
502
- },
503
- ...init.body === void 0 ? {} : { body: JSON.stringify(init.body) }
504
- });
505
- const text = await response.text();
800
+ let response;
801
+ let text;
802
+ try {
803
+ response = await fetch(new URL(path, apiUrl), {
804
+ method: init.method ?? "GET",
805
+ headers: {
806
+ authorization: `Bearer ${apiKey}`,
807
+ "content-type": "application/json",
808
+ ...init.claim ? { "x-bcms-component-claim": init.claim } : {}
809
+ },
810
+ ...init.body === void 0 ? {} : { body: JSON.stringify(init.body) },
811
+ signal: AbortSignal.timeout(API_TIMEOUT_MS)
812
+ });
813
+ text = await response.text();
814
+ } catch (error) {
815
+ const name = error.name;
816
+ if (name === "TimeoutError" || name === "AbortError") {
817
+ throw new ValidationFailure("COMPONENT_VALIDATION_API_TIMEOUT", `${init.method ?? "GET"} ${path} did not answer within ${API_TIMEOUT_MS / 1e3} seconds.`);
818
+ }
819
+ throw error;
820
+ }
506
821
  let parsed = null;
507
822
  try {
508
823
  parsed = JSON.parse(text);
@@ -516,38 +831,10 @@ async function validateComponent() {
516
831
  }
517
832
  return (parsed && "data" in parsed ? parsed.data : parsed) ?? {};
518
833
  };
519
- const claimPath = `/api/v1/projects/${projectId}/component-implementation/implementation-requests/${requestId}`;
520
- let claim = null;
521
- const claimRequest = async () => {
522
- claim = await api(`${claimPath}/claim`, {
523
- method: "POST",
524
- body: {
525
- componentId,
526
- commitSha,
527
- adapter: { protocol: "bcms-component-runtime-v1", kind: "project-route", path: RUNTIME_PATH, familyKey, previewOrigin, nativeViewports },
528
- providerRunId: required("BCMS_RUN_ID"),
529
- providerRunAttempt: Number(process.env.BCMS_RUN_ATTEMPT) || 1,
530
- providerRunUrl: required("BCMS_RUN_URL"),
531
- workflowRef: required("BCMS_WORKFLOW_REF"),
532
- // The server caps this at the request's own expiry and at ten minutes.
533
- credentialExpiresAt: new Date(Date.now() + 10 * 6e4 - 5e3).toISOString()
534
- }
535
- });
536
- return claim;
537
- };
538
834
  const work = mkdtempSync(join2(tmpdir(), "bcms-validate-"));
539
- let runtime = null;
540
- try {
541
- const manifest = await api(`/api/v1/projects/${projectId}/component-preview/manifest`);
542
- const entry = manifest.components.find((c) => c.id === componentId);
543
- if (!entry) {
544
- throw new ValidationFailure(
545
- "COMPONENT_SOURCE_NOT_RECORDED",
546
- "No file is recorded as this component's source. The agent that writes a component records it with set_component_source."
547
- );
548
- }
835
+ const prepare = async (manifest) => {
549
836
  const manifestPath = join2(work, "manifest.json");
550
- writeFileSync2(manifestPath, JSON.stringify({ components: manifest.components.map(({ id: id2, source }) => ({ id: id2, source })) }));
837
+ writeFileSync2(manifestPath, JSON.stringify({ components: manifest.components.map(({ id, source, fallback }) => ({ id, source, ...fallback ? { fallback } : {} })) }));
551
838
  const out = join2(work, "runtime");
552
839
  let built;
553
840
  try {
@@ -555,12 +842,17 @@ async function validateComponent() {
555
842
  } catch (error) {
556
843
  throw new ValidationFailure("COMPONENT_PREVIEW_BUILD_FAILED", `The preview build failed: ${error.message}`);
557
844
  }
845
+ const tarball = join2(work, "bundle.tgz");
846
+ if (spawnSync2("tar", ["-czf", tarball, "-C", out, "."], { stdio: "inherit" }).status !== 0) {
847
+ throw new ValidationFailure("COMPONENT_PREVIEW_BUNDLE_PACK_FAILED", "The preview runtime could not be packaged.");
848
+ }
849
+ const bundle = readFileSync2(tarball);
558
850
  const runtimeManifest = JSON.parse(readFileSync2(join2(out, "bcms-runtime.json"), "utf8"));
559
851
  const port = await freePort();
560
852
  const validatorKey = randomBytes(32).toString("base64url");
561
853
  const local = `http://127.0.0.1:${port}`;
562
854
  const { BCMS_API_KEY: _withheld, ...inherited } = process.env;
563
- runtime = spawn(process.execPath, [join2(out, runtimeManifest.dir, runtimeManifest.entry)], {
855
+ const child = spawn(process.execPath, [join2(out, runtimeManifest.dir, runtimeManifest.entry)], {
564
856
  cwd: join2(out, runtimeManifest.dir),
565
857
  env: {
566
858
  ...inherited,
@@ -580,106 +872,76 @@ async function validateComponent() {
580
872
  stream.write(chunk);
581
873
  for (const line of chunk.toString("utf8").split("\n")) {
582
874
  const clean = line.replace(/\x1b\[[0-9;]*m/g, "").trim();
583
- if (serverErrors.length < 5 && /\b(error|exception)\b/i.test(clean)) serverErrors.push(clean.slice(0, 200));
875
+ if (serverErrors.length < 500 && /\b(error|exception)\b/i.test(clean)) serverErrors.push(clean.slice(0, 200));
584
876
  }
585
877
  };
586
- runtime.stdout?.on("data", collect(process.stdout));
587
- runtime.stderr?.on("data", collect(process.stderr));
588
- if (!await waitForOk(`${local}${PREVIEW_ROUTE_BASE}/health`, 6e4)) {
589
- throw new ValidationFailure("COMPONENT_PREVIEW_RUNTIME_DID_NOT_START", "The preview runtime did not start within 60 seconds.");
590
- }
591
- const stored = await fetch(`${local}${PREVIEW_ROUTE_BASE}/props`, {
592
- method: "POST",
593
- headers: { "content-type": "application/json", "x-bcms-validator-key": validatorKey },
594
- body: JSON.stringify({ componentId, props: entry.defaultProps })
595
- });
596
- if (!stored.ok) {
597
- throw new ValidationFailure("COMPONENT_PREVIEW_RUNTIME_REFUSED", `The preview runtime refused the component (HTTP ${stored.status}).`);
598
- }
599
- const { id } = await stored.json();
600
- const render = await renderInBrowser(
601
- `${local}${PREVIEW_ROUTE_BASE}/render?id=${encodeURIComponent(id)}`,
602
- nativeViewports,
603
- manifest.brandTokenNames,
604
- { section: built.kinds[componentId] === "section" }
605
- );
606
- const tarball = join2(work, "bundle.tgz");
607
- if (spawnSync2("tar", ["-czf", tarball, "-C", out, "."], { stdio: "inherit" }).status !== 0) {
608
- throw new ValidationFailure("COMPONENT_PREVIEW_BUNDLE_PACK_FAILED", "The preview runtime could not be packaged.");
878
+ child.stdout?.on("data", collect(process.stdout));
879
+ child.stderr?.on("data", collect(process.stderr));
880
+ let browser = null;
881
+ try {
882
+ if (!await waitForOk(`${local}${PREVIEW_ROUTE_BASE}/health`, 6e4)) {
883
+ throw new ValidationFailure("COMPONENT_PREVIEW_RUNTIME_DID_NOT_START", "The preview runtime did not start within 60 seconds.");
884
+ }
885
+ ensureBrowser();
886
+ const { chromium } = await import("playwright");
887
+ browser = await chromium.launch();
888
+ } catch (error) {
889
+ child.kill();
890
+ throw error;
609
891
  }
610
- const bytes = readFileSync2(tarball);
611
- runtime.kill();
612
- runtime = null;
613
- const target = (await claimRequest()).request;
614
- const checks = {
615
- brandKit: {
616
- status: render.missingTokens.length === 0 ? "passed" : "failed",
617
- contractHash: target.brandContractHash,
618
- missingTokens: render.missingTokens
619
- },
620
- runtime: {
621
- status: render.runtimeErrors === 0 && render.consoleErrors === 0 ? "passed" : "failed",
622
- runtimeErrors: render.runtimeErrors,
623
- consoleErrors: render.consoleErrors,
624
- // Only on a failure, and bounded: the server's own error lines first (the real cause), then what the
625
- // browser saw. The database stores them and the panel shows the first one.
626
- ...render.runtimeErrors + render.consoleErrors > 0 ? { problems: [...serverErrors.map((line) => `server: ${line}`), ...render.problems].slice(0, 10).map((line) => line.slice(0, 200)) } : {}
892
+ const shared = browser;
893
+ return {
894
+ kinds: built.kinds,
895
+ bundle,
896
+ storeProps: async (componentId, props, signal) => {
897
+ const stored = await fetch(`${local}${PREVIEW_ROUTE_BASE}/props`, {
898
+ method: "POST",
899
+ headers: { "content-type": "application/json", "x-bcms-validator-key": validatorKey },
900
+ body: JSON.stringify({ componentId, props }),
901
+ signal
902
+ });
903
+ if (!stored.ok) {
904
+ throw new ValidationFailure("COMPONENT_PREVIEW_RUNTIME_REFUSED", `The preview runtime refused the component (HTTP ${stored.status}).`);
905
+ }
906
+ return (await stored.json()).id;
627
907
  },
628
- visual: { status: "baseline-missing", reviewRequired: true, viewports: render.results }
629
- };
630
- const upload = await api(`/api/v1/projects/${projectId}/artifacts/upload-url`, { method: "POST" });
631
- const put = await fetch(upload.uploadUrl, { method: "PUT", body: bytes, headers: { "content-type": "application/gzip" } });
632
- if (!put.ok) {
633
- throw new ValidationFailure("COMPONENT_PREVIEW_BUNDLE_UPLOAD_FAILED", `Storage refused the preview bundle (HTTP ${put.status}).`);
634
- }
635
- await api(`/api/v1/projects/${projectId}/component-preview/bundles`, {
636
- method: "POST",
637
- body: { commitSha: target.commitSha, uploadKey: upload.uploadKey, checksum: `sha256:${sha256(bytes)}`, sizeBytes: bytes.byteLength }
638
- });
639
- const evidenceDigest = `sha256:${sha256(canonical({ requestId, commitSha: target.commitSha, checks }))}`;
640
- await api(`${claimPath}/complete`, {
641
- method: "POST",
642
- claim: claim.claimCapability,
643
- body: {
644
- componentId: target.componentId,
645
- candidateId: target.candidateId,
646
- componentVersion: target.componentVersion,
647
- familyKey: target.familyKey,
648
- familyContractHash: target.familyContractHash,
649
- schemaHash: target.schemaHash,
650
- brandContractHash: target.brandContractHash,
651
- dependenciesHash: target.dependenciesHash,
652
- commitSha: target.commitSha,
653
- adapterHash: target.adapterHash,
654
- familyManifestHash: target.familyManifestHash,
655
- nativeViewports: target.nativeViewports,
656
- checks,
657
- evidenceDigest
908
+ render: (renderId, viewports, tokenNames, options, signal) => renderInBrowser(shared, `${local}${PREVIEW_ROUTE_BASE}/render?id=${encodeURIComponent(renderId)}`, viewports, tokenNames, options, signal),
909
+ serverErrorCount: () => serverErrors.length,
910
+ serverErrorsSince: (mark) => serverErrors.slice(mark),
911
+ close: async () => {
912
+ child.kill();
913
+ await shared.close().catch(() => {
914
+ });
658
915
  }
916
+ };
917
+ };
918
+ const budget = Number(process.env.BCMS_BATCH_BUDGET_MS);
919
+ try {
920
+ const result = await runValidationBatch(batch, ctx, {
921
+ api,
922
+ prepare,
923
+ putBundle: async (uploadUrl, bytes) => {
924
+ try {
925
+ const put = await fetch(uploadUrl, {
926
+ method: "PUT",
927
+ body: new Uint8Array(bytes),
928
+ headers: { "content-type": "application/gzip" },
929
+ signal: AbortSignal.timeout(BUNDLE_UPLOAD_TIMEOUT_MS)
930
+ });
931
+ return { ok: put.ok, status: put.status };
932
+ } catch (error) {
933
+ const name = error.name;
934
+ if (name !== "TimeoutError" && name !== "AbortError") throw error;
935
+ throw new ValidationFailure("COMPONENT_PREVIEW_BUNDLE_UPLOAD_FAILED", `Storage did not accept the preview bundle within ${BUNDLE_UPLOAD_TIMEOUT_MS / 6e4} minutes.`);
936
+ }
937
+ },
938
+ budgetMs: Number.isFinite(budget) && budget > 0 ? budget : DEFAULT_BATCH_BUDGET_MS
659
939
  });
660
- const passed = checks.brandKit.status === "passed" && checks.runtime.status === "passed";
661
- console.log(`bcms-preview: validation ${passed ? "PASSED" : "FAILED"} for ${componentId}`);
662
- if (!passed) {
663
- if (render.missingTokens.length) console.log(` brand tokens used but not defined: ${render.missingTokens.join(", ")}`);
664
- for (const problem of render.problems) console.log(` ${problem}`);
665
- }
666
- } catch (error) {
667
- const code = error instanceof ValidationFailure ? error.code : "COMPONENT_VALIDATION_FAILED";
668
- const message = error.message.slice(0, 2e3);
669
- const reported = claim ?? await claimRequest().catch((claimError) => {
670
- console.error(`bcms-preview: could not claim the request to report the failure: ${claimError.message}`);
671
- return null;
672
- });
673
- if (reported) {
674
- await api(`${claimPath}/fail`, {
675
- method: "POST",
676
- claim: reported.claimCapability,
677
- body: { componentId, errorCode: code, errorMessage: message }
678
- }).catch((reportError) => console.error(`bcms-preview: could not report the failure: ${reportError.message}`));
940
+ if (batch.length > 1) {
941
+ console.log(`bcms-preview: ${result.completed.length} of ${batch.length} components completed, ${result.failed.length} could not be validated`);
679
942
  }
680
- throw new CliFailure(`${code}: ${message}`);
943
+ if (result.exitCode) process.exitCode = 1;
681
944
  } finally {
682
- runtime?.kill();
683
945
  rmSync2(work, { recursive: true, force: true });
684
946
  }
685
947
  }