@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/README.md +23 -2
- package/dist/cli.js +431 -169
- package/dist/cli.js.map +1 -1
- package/dist/scope-client.global.js +1 -0
- package/dist/server.d.ts +40 -1
- package/dist/server.js +54 -2
- package/dist/shell.global.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 <
|
|
875
|
+
if (serverErrors.length < 500 && /\b(error|exception)\b/i.test(clean)) serverErrors.push(clean.slice(0, 200));
|
|
584
876
|
}
|
|
585
877
|
};
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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
|
}
|