feat: add optional frontend adapter recipes
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const REQUIRED_RECIPE_IDS = Object.freeze([
|
||||
"analytics-error-sink",
|
||||
"browser-permission",
|
||||
"client-workflow",
|
||||
"feature-flag",
|
||||
"file-transfer",
|
||||
"generated-api",
|
||||
"large-data-ui",
|
||||
"multi-tab",
|
||||
"offline-indexeddb",
|
||||
"realtime",
|
||||
"service-worker-pwa",
|
||||
"web-worker",
|
||||
]);
|
||||
|
||||
const lifecycleRecipes = new Set([
|
||||
"analytics-error-sink",
|
||||
"browser-permission",
|
||||
"client-workflow",
|
||||
"file-transfer",
|
||||
"generated-api",
|
||||
"multi-tab",
|
||||
"offline-indexeddb",
|
||||
"realtime",
|
||||
"service-worker-pwa",
|
||||
"web-worker",
|
||||
]);
|
||||
|
||||
/** @param {unknown} value */
|
||||
function nonEmptyStrings(value) {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((entry) => typeof entry === "string" && entry.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} input
|
||||
* @param {Readonly<Record<string, unknown>>} packageDocument
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function validateRecipeCatalog(input, packageDocument) {
|
||||
const document =
|
||||
/** @type {Record<string, any>} */ (
|
||||
input && typeof input === "object" ? input : {}
|
||||
);
|
||||
/** @type {string[]} */
|
||||
const violations = [];
|
||||
if (document.schemaVersion !== 1) violations.push("CATALOG_SCHEMA_VERSION");
|
||||
if (document.decisionId !== "VD-10") violations.push("CATALOG_DECISION");
|
||||
if (document.defaultStatus !== "NOT_INSTALLED") {
|
||||
violations.push("CATALOG_DEFAULT_MUST_BE_NOT_INSTALLED");
|
||||
}
|
||||
if (
|
||||
!Array.isArray(document.productionRuntimeDependencies) ||
|
||||
document.productionRuntimeDependencies.length > 0
|
||||
) {
|
||||
violations.push("UNSELECTED_RUNTIME_DEPENDENCY");
|
||||
}
|
||||
if (!nonEmptyStrings(document.vendorPackagePatterns)) {
|
||||
violations.push("VENDOR_PATTERN_CATALOG");
|
||||
}
|
||||
if (!Array.isArray(document.recipes)) {
|
||||
return [...violations, "RECIPE_CATALOG_MISSING"];
|
||||
}
|
||||
|
||||
const actualIds = document.recipes
|
||||
.map(/** @param {Record<string, unknown>} recipe */ (recipe) => recipe.id)
|
||||
.sort();
|
||||
if (JSON.stringify(actualIds) !== JSON.stringify(REQUIRED_RECIPE_IDS)) {
|
||||
violations.push("RECIPE_ID_SET");
|
||||
}
|
||||
if (new Set(actualIds).size !== actualIds.length) {
|
||||
violations.push("RECIPE_ID_DUPLICATE");
|
||||
}
|
||||
|
||||
for (const recipe of document.recipes) {
|
||||
const id = typeof recipe.id === "string" ? recipe.id : "unknown";
|
||||
if (recipe.status !== "RECIPE_AVAILABLE") {
|
||||
violations.push(`${id}:STATUS_MUST_NOT_CLAIM_INSTALLED`);
|
||||
}
|
||||
for (const field of [
|
||||
"trigger",
|
||||
"boundary",
|
||||
"port",
|
||||
"fake",
|
||||
"owner",
|
||||
"fallback",
|
||||
"serverStatePolicy",
|
||||
]) {
|
||||
if (typeof recipe[field] !== "string" || recipe[field].trim().length === 0) {
|
||||
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
for (const field of [
|
||||
"forbiddenWhen",
|
||||
"failureKinds",
|
||||
"securityPrivacy",
|
||||
"removal",
|
||||
]) {
|
||||
if (!nonEmptyStrings(recipe[field])) {
|
||||
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(recipe.bundleBudgetGzipBytes) ||
|
||||
recipe.bundleBudgetGzipBytes < 1
|
||||
) {
|
||||
violations.push(`${id}:INVALID_BUNDLE_BUDGET`);
|
||||
}
|
||||
if (recipe.owner === "frontend-platform") {
|
||||
violations.push(`${id}:PROJECT_OWNER_NOT_ASSIGNED`);
|
||||
}
|
||||
if (
|
||||
lifecycleRecipes.has(id) &&
|
||||
!nonEmptyStrings(recipe.lifecycleMethods)
|
||||
) {
|
||||
violations.push(`${id}:CLEANUP_CONTRACT_MISSING`);
|
||||
}
|
||||
if (
|
||||
id === "client-workflow" &&
|
||||
recipe.serverStatePolicy !== "reference-only"
|
||||
) {
|
||||
violations.push(`${id}:SERVER_STATE_DUPLICATION_POLICY`);
|
||||
}
|
||||
}
|
||||
|
||||
const dependencies = {
|
||||
.../** @type {Record<string, string>} */ (packageDocument.dependencies ?? {}),
|
||||
.../** @type {Record<string, string>} */ (
|
||||
packageDocument.devDependencies ?? {}
|
||||
),
|
||||
};
|
||||
for (const pattern of document.vendorPackagePatterns ?? []) {
|
||||
const wildcard = String(pattern).endsWith("*");
|
||||
const prefix = String(pattern).replace(/\/?\*$/, "");
|
||||
if (
|
||||
Object.keys(dependencies).some(
|
||||
(dependency) =>
|
||||
dependency === prefix ||
|
||||
dependency.startsWith(`${prefix}/`) ||
|
||||
(wildcard && dependency.startsWith(prefix)),
|
||||
)
|
||||
) {
|
||||
violations.push(`UNSELECTED_VENDOR_INSTALLED:${prefix}`);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
export async function sourceFiles(directory) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory()
|
||||
? sourceFiles(target)
|
||||
: /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)
|
||||
? [target]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} root
|
||||
* @param {{scanProductionBoundary?: boolean}} [options]
|
||||
*/
|
||||
export async function scanOptionalRecipeSources(
|
||||
root,
|
||||
{ scanProductionBoundary = true } = {},
|
||||
) {
|
||||
/** @type {Array<{ruleId: string; path: string}>} */
|
||||
const violations = [];
|
||||
for (const file of await sourceFiles(root)) {
|
||||
const relative = path.relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
const relativeToRoot = path.relative(root, file).replaceAll("\\", "/");
|
||||
const content = await readFile(file, "utf8");
|
||||
const imports = [
|
||||
...content.matchAll(
|
||||
/(?:from\s*|import\s*\(\s*)["']([^"']+)["']/g,
|
||||
),
|
||||
].map((match) => match[1]);
|
||||
|
||||
if (
|
||||
scanProductionBoundary &&
|
||||
(relativeToRoot.startsWith("src/") ||
|
||||
(path.basename(path.resolve(root)) === "src" &&
|
||||
!relativeToRoot.startsWith(".."))) &&
|
||||
imports.some((specifier) =>
|
||||
/(?:^|\/)recipes\/frontend-capabilities(?:\/|$)/.test(specifier),
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "PRODUCTION_IMPORTS_RECIPE", path: relative });
|
||||
}
|
||||
|
||||
const localVendorAdapter =
|
||||
relative.includes("recipes/") && relative.includes("/adapters/");
|
||||
if (
|
||||
!localVendorAdapter &&
|
||||
imports.some((specifier) =>
|
||||
/^(?:@launchdarkly\/|@sentry\/|@opentelemetry\/|@openapitools\/openapi-generator-cli$|@reduxjs\/toolkit$|@tanstack\/react-virtual$|@uppy\/|firebase(?:\/|$)|idb$|react-window$|redux(?:\/|$)|socket\.io-client$|tus-js-client$|workbox-window$|xstate$|zustand$)/.test(
|
||||
specifier,
|
||||
),
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "VENDOR_IMPORT_OUTSIDE_ADAPTER", path: relative });
|
||||
}
|
||||
|
||||
if (
|
||||
/localStorage\s*\.\s*(?:setItem|getItem)\s*\([^)]*(?:credential|password|secret|token)/is.test(
|
||||
content,
|
||||
) ||
|
||||
/searchParams\s*\.\s*set\s*\(\s*["'](?:credential|password|secret|token)/is.test(
|
||||
content,
|
||||
) ||
|
||||
/(?:record|track|emit)\s*\(\s*\{[\s\S]{0,400}(?:credential|password|secret|token)\s*:/i.test(
|
||||
content,
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "CREDENTIAL_LEAK_PATH", path: relative });
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:createStore|configureStore|create\s*\()\s*\([\s\S]{0,600}(?:apiResponse|queryData|serverState)\s*:/i.test(
|
||||
content,
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "CLIENT_STORE_DUPLICATES_SERVER_STATE", path: relative });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** @param {string} distRoot */
|
||||
export async function scanProductionBundle(distRoot) {
|
||||
/** @type {string[]} */
|
||||
const violations = [];
|
||||
for (const file of await sourceFiles(distRoot)) {
|
||||
const content = await readFile(file, "utf8");
|
||||
if (content.includes("frontend-optional-recipe-must-not-reach-production")) {
|
||||
violations.push(path.relative(process.cwd(), file));
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
Reference in New Issue
Block a user