mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-09 11:51:15 +02:00
1021 lines
37 KiB
TypeScript
1021 lines
37 KiB
TypeScript
// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Vite's build plugin runs before an Effect runtime exists.
|
|
|
|
import * as NodeFSP from "node:fs/promises";
|
|
import * as NodePath from "node:path";
|
|
import * as NodeURL from "node:url";
|
|
import * as NodeModule from "node:module";
|
|
|
|
import type { Plugin } from "vite-plus";
|
|
|
|
export const THIRD_PARTY_LICENSES_FILE_NAME = "third-party-licenses.json";
|
|
const SPDX_LICENSE_LIST_VERSION = "v3.28.0";
|
|
const SPDX_LICENSE_LIST_REVISION = "c4a7237ec8f4654e867546f9f409749300f1bf4c";
|
|
const GENERATED_NOTICE_CACHE_DIRECTORY = ".generated/third-party-licenses/spdx";
|
|
|
|
export interface ThirdPartyLicenseEntry {
|
|
readonly bundles: ReadonlyArray<string>;
|
|
readonly kind: "custom" | "package";
|
|
readonly license: string;
|
|
readonly name: string;
|
|
readonly noticeText: string;
|
|
readonly sourceUrl: string | null;
|
|
readonly version: string | null;
|
|
}
|
|
|
|
export interface ThirdPartyLicenseManifest {
|
|
readonly schemaVersion: 1;
|
|
readonly entries: ReadonlyArray<ThirdPartyLicenseEntry>;
|
|
}
|
|
|
|
export interface ThirdPartyLicensePackageManifest {
|
|
readonly bundle: string;
|
|
readonly path: string | URL;
|
|
}
|
|
|
|
export interface ThirdPartyLicensesPluginOptions {
|
|
readonly configFile?: string | URL;
|
|
readonly packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>;
|
|
readonly bundleName: string;
|
|
}
|
|
|
|
interface GeneratedNoticeConfigEntry {
|
|
readonly copyrights?: ReadonlyArray<string>;
|
|
readonly licenseId: string;
|
|
readonly preamble?: ReadonlyArray<string>;
|
|
}
|
|
|
|
interface PackageJson {
|
|
readonly dependencies?: Readonly<Record<string, string>>;
|
|
readonly homepage?: unknown;
|
|
readonly license?: unknown;
|
|
readonly licenses?: unknown;
|
|
readonly name?: unknown;
|
|
readonly optionalDependencies?: Readonly<Record<string, string>>;
|
|
readonly repository?: unknown;
|
|
readonly version?: unknown;
|
|
}
|
|
|
|
interface CustomNoticeConfigEntry {
|
|
readonly bundles?: ReadonlyArray<string>;
|
|
readonly includeInBundles?: ReadonlyArray<string>;
|
|
readonly license: string;
|
|
readonly name: string;
|
|
readonly generatedNotices?: ReadonlyArray<GeneratedNoticeConfigEntry>;
|
|
readonly noticeFiles?: ReadonlyArray<string>;
|
|
readonly sourceUrl?: string;
|
|
readonly version?: string;
|
|
}
|
|
|
|
interface PackageNoticeOverrideConfigEntry {
|
|
readonly generatedNotices?: ReadonlyArray<GeneratedNoticeConfigEntry>;
|
|
readonly license?: string;
|
|
readonly name?: string;
|
|
readonly noticeFile?: string;
|
|
readonly repositoryUrl?: string;
|
|
readonly sourceUrl?: string;
|
|
readonly version?: string;
|
|
}
|
|
|
|
interface ThirdPartyLicensesConfig {
|
|
readonly customNotices: ReadonlyArray<CustomNoticeConfigEntry>;
|
|
readonly packageOverrides: ReadonlyArray<PackageNoticeOverrideConfigEntry>;
|
|
}
|
|
|
|
interface SpdxLicenseDetails {
|
|
readonly licenseId: string;
|
|
readonly licenseText: string;
|
|
}
|
|
|
|
interface CollectedPackage {
|
|
readonly bundles: Set<string>;
|
|
readonly packageJson: PackageJson;
|
|
readonly packageRoot: string;
|
|
}
|
|
|
|
interface PackageCollection {
|
|
readonly byIdentity: Map<string, CollectedPackage>;
|
|
}
|
|
|
|
const EMPTY_CONFIG: ThirdPartyLicensesConfig = {
|
|
customNotices: [],
|
|
packageOverrides: [],
|
|
};
|
|
|
|
const NOTICE_FILE_PATTERN = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/i;
|
|
const NOTICE_TEXT_EXTENSIONS = new Set([
|
|
"",
|
|
".0bsd",
|
|
".agpl",
|
|
".apache2",
|
|
".bsd",
|
|
".gpl",
|
|
".isc",
|
|
".lgpl",
|
|
".markdown",
|
|
".md",
|
|
".mit",
|
|
".mpl",
|
|
".mpl2",
|
|
".rst",
|
|
".txt",
|
|
".unlicense",
|
|
]);
|
|
const FIRST_PARTY_PACKAGE_PREFIX = "@t3tools/";
|
|
|
|
function isNoticeTextFile(fileName: string): boolean {
|
|
return (
|
|
NOTICE_FILE_PATTERN.test(fileName) &&
|
|
NOTICE_TEXT_EXTENSIONS.has(NodePath.extname(fileName).toLowerCase())
|
|
);
|
|
}
|
|
|
|
function asPath(value: string | URL): string {
|
|
return value instanceof URL ? NodeURL.fileURLToPath(value) : NodePath.resolve(value);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function readRequiredString(value: Record<string, unknown>, key: string, context: string): string {
|
|
const field = value[key];
|
|
if (typeof field !== "string" || field.trim().length === 0) {
|
|
throw new Error(`${context} must define a non-empty "${key}" string.`);
|
|
}
|
|
return field.trim();
|
|
}
|
|
|
|
function readOptionalString(
|
|
value: Record<string, unknown>,
|
|
key: string,
|
|
context: string,
|
|
): string | undefined {
|
|
const field = value[key];
|
|
if (field === undefined) return undefined;
|
|
if (typeof field !== "string" || field.trim().length === 0) {
|
|
throw new Error(`${context} must define "${key}" as a non-empty string when present.`);
|
|
}
|
|
return field.trim();
|
|
}
|
|
|
|
function readOptionalStringArray(
|
|
value: Record<string, unknown>,
|
|
key: string,
|
|
context: string,
|
|
): ReadonlyArray<string> | undefined {
|
|
const field = value[key];
|
|
if (field === undefined) return undefined;
|
|
if (
|
|
!Array.isArray(field) ||
|
|
field.length === 0 ||
|
|
field.some((entry) => typeof entry !== "string" || entry.trim().length === 0)
|
|
) {
|
|
throw new Error(`${context} must define "${key}" as a non-empty string array.`);
|
|
}
|
|
return field.map((entry) => (entry as string).trim());
|
|
}
|
|
|
|
function decodeGeneratedNotice(value: unknown, context: string): GeneratedNoticeConfigEntry {
|
|
if (!isRecord(value)) throw new Error(`${context} must be an object.`);
|
|
const copyrights = readOptionalStringArray(value, "copyrights", context);
|
|
const preamble = readOptionalStringArray(value, "preamble", context);
|
|
return {
|
|
licenseId: readRequiredString(value, "licenseId", context),
|
|
...(copyrights !== undefined ? { copyrights } : {}),
|
|
...(preamble !== undefined ? { preamble } : {}),
|
|
};
|
|
}
|
|
|
|
function decodeGeneratedNotices(
|
|
value: unknown,
|
|
context: string,
|
|
): ReadonlyArray<GeneratedNoticeConfigEntry> | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!Array.isArray(value) || value.length === 0) {
|
|
throw new Error(`${context} must define "generatedNotices" as a non-empty array.`);
|
|
}
|
|
return value.map((entry, index) =>
|
|
decodeGeneratedNotice(entry, `${context} generated notice at index ${String(index)}`),
|
|
);
|
|
}
|
|
|
|
function decodeCustomNotices(value: unknown): ReadonlyArray<CustomNoticeConfigEntry> {
|
|
if (value === undefined) return [];
|
|
if (!Array.isArray(value)) {
|
|
throw new Error('Third-party license config field "customNotices" must be an array.');
|
|
}
|
|
return value.map((entry, index) => {
|
|
const context = `Third-party custom notice at index ${String(index)}`;
|
|
if (!isRecord(entry)) throw new Error(`${context} must be an object.`);
|
|
const version = readOptionalString(entry, "version", context);
|
|
const sourceUrl = readOptionalString(entry, "sourceUrl", context);
|
|
const bundles = readOptionalStringArray(entry, "bundles", context);
|
|
const includeInBundles = readOptionalStringArray(entry, "includeInBundles", context);
|
|
const noticeFile = readOptionalString(entry, "noticeFile", context);
|
|
const noticeFiles = readOptionalStringArray(entry, "noticeFiles", context);
|
|
const generatedNotices = decodeGeneratedNotices(entry.generatedNotices, context);
|
|
const noticeSourceCount =
|
|
Number(noticeFile !== undefined) +
|
|
Number(noticeFiles !== undefined) +
|
|
Number(generatedNotices !== undefined);
|
|
if (noticeSourceCount !== 1) {
|
|
throw new Error(
|
|
`${context} must define exactly one of "noticeFile", "noticeFiles", or "generatedNotices".`,
|
|
);
|
|
}
|
|
return {
|
|
name: readRequiredString(entry, "name", context),
|
|
license: readRequiredString(entry, "license", context),
|
|
...(noticeFile !== undefined ? { noticeFiles: [noticeFile] } : {}),
|
|
...(noticeFiles !== undefined ? { noticeFiles } : {}),
|
|
...(generatedNotices !== undefined ? { generatedNotices } : {}),
|
|
...(version !== undefined ? { version } : {}),
|
|
...(sourceUrl !== undefined ? { sourceUrl } : {}),
|
|
...(bundles !== undefined ? { bundles } : {}),
|
|
...(includeInBundles !== undefined ? { includeInBundles } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
function decodePackageOverrides(value: unknown): ReadonlyArray<PackageNoticeOverrideConfigEntry> {
|
|
if (value === undefined) return [];
|
|
if (!Array.isArray(value)) {
|
|
throw new Error('Third-party license config field "packageOverrides" must be an array.');
|
|
}
|
|
return value.map((entry, index) => {
|
|
const context = `Third-party package override at index ${String(index)}`;
|
|
if (!isRecord(entry)) throw new Error(`${context} must be an object.`);
|
|
const name = readOptionalString(entry, "name", context);
|
|
const repositoryUrl = readOptionalString(entry, "repositoryUrl", context);
|
|
if ((name === undefined) === (repositoryUrl === undefined)) {
|
|
throw new Error(`${context} must define exactly one of "name" or "repositoryUrl".`);
|
|
}
|
|
const version = readOptionalString(entry, "version", context);
|
|
const license = readOptionalString(entry, "license", context);
|
|
const noticeFile = readOptionalString(entry, "noticeFile", context);
|
|
const generatedNotice =
|
|
entry.generatedNotice === undefined
|
|
? undefined
|
|
: decodeGeneratedNotice(entry.generatedNotice, `${context} generated notice`);
|
|
const generatedNotices = decodeGeneratedNotices(entry.generatedNotices, context);
|
|
if (
|
|
Number(noticeFile !== undefined) +
|
|
Number(generatedNotice !== undefined) +
|
|
Number(generatedNotices !== undefined) >
|
|
1
|
|
) {
|
|
throw new Error(
|
|
`${context} can define only one of "noticeFile", "generatedNotice", or "generatedNotices".`,
|
|
);
|
|
}
|
|
const sourceUrl = readOptionalString(entry, "sourceUrl", context);
|
|
return {
|
|
...(name !== undefined ? { name } : {}),
|
|
...(repositoryUrl !== undefined ? { repositoryUrl } : {}),
|
|
...(version !== undefined ? { version } : {}),
|
|
...(license !== undefined ? { license } : {}),
|
|
...(noticeFile !== undefined ? { noticeFile } : {}),
|
|
...(generatedNotice !== undefined ? { generatedNotices: [generatedNotice] } : {}),
|
|
...(generatedNotices !== undefined ? { generatedNotices } : {}),
|
|
...(sourceUrl !== undefined ? { sourceUrl } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
async function readConfig(configFile: string | URL | undefined): Promise<{
|
|
readonly config: ThirdPartyLicensesConfig;
|
|
readonly directory: string;
|
|
}> {
|
|
if (configFile === undefined) {
|
|
return { config: EMPTY_CONFIG, directory: NodePath.resolve(".") };
|
|
}
|
|
const configPath = asPath(configFile);
|
|
const source = await NodeFSP.readFile(configPath, "utf8");
|
|
const decoded = JSON.parse(source) as unknown;
|
|
if (!isRecord(decoded)) throw new Error("Third-party license config must contain an object.");
|
|
return {
|
|
config: {
|
|
customNotices: decodeCustomNotices(decoded.customNotices),
|
|
packageOverrides: decodePackageOverrides(decoded.packageOverrides),
|
|
},
|
|
directory: NodePath.dirname(configPath),
|
|
};
|
|
}
|
|
|
|
function spdxLicenseCachePath(configDirectory: string, licenseId: string): string {
|
|
return NodePath.join(
|
|
configDirectory,
|
|
GENERATED_NOTICE_CACHE_DIRECTORY,
|
|
SPDX_LICENSE_LIST_VERSION,
|
|
`${licenseId}.json`,
|
|
);
|
|
}
|
|
|
|
function decodeSpdxLicenseDetails(value: unknown, expectedLicenseId: string): SpdxLicenseDetails {
|
|
if (
|
|
!isRecord(value) ||
|
|
value.licenseId !== expectedLicenseId ||
|
|
typeof value.licenseText !== "string" ||
|
|
value.licenseText.trim().length === 0
|
|
) {
|
|
throw new Error(`SPDX returned invalid license details for ${expectedLicenseId}.`);
|
|
}
|
|
return { licenseId: expectedLicenseId, licenseText: value.licenseText.trim() };
|
|
}
|
|
|
|
async function readCachedSpdxLicense(
|
|
configDirectory: string,
|
|
licenseId: string,
|
|
): Promise<SpdxLicenseDetails | null> {
|
|
try {
|
|
const source = await NodeFSP.readFile(spdxLicenseCachePath(configDirectory, licenseId), "utf8");
|
|
return decodeSpdxLicenseDetails(JSON.parse(source) as unknown, licenseId);
|
|
} catch (error) {
|
|
const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
|
|
if (code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function downloadSpdxLicense(
|
|
configDirectory: string,
|
|
licenseId: string,
|
|
): Promise<SpdxLicenseDetails> {
|
|
const url = `https://raw.githubusercontent.com/spdx/license-list-data/${SPDX_LICENSE_LIST_REVISION}/json/details/${encodeURIComponent(licenseId)}.json`;
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Could not download SPDX license ${licenseId}: HTTP ${String(response.status)}.`,
|
|
);
|
|
}
|
|
const details = decodeSpdxLicenseDetails((await response.json()) as unknown, licenseId);
|
|
const cachePath = spdxLicenseCachePath(configDirectory, licenseId);
|
|
await NodeFSP.mkdir(NodePath.dirname(cachePath), { recursive: true });
|
|
await NodeFSP.writeFile(cachePath, `${JSON.stringify(details)}\n`, "utf8");
|
|
return details;
|
|
}
|
|
|
|
async function resolveSpdxLicense(
|
|
configDirectory: string,
|
|
licenseId: string,
|
|
allowMissing: boolean,
|
|
): Promise<SpdxLicenseDetails | null> {
|
|
const cached = await readCachedSpdxLicense(configDirectory, licenseId);
|
|
if (cached || allowMissing) return cached;
|
|
return downloadSpdxLicense(configDirectory, licenseId);
|
|
}
|
|
|
|
function renderGeneratedNotice(config: GeneratedNoticeConfigEntry, licenseText: string): string {
|
|
const copyrights = config.copyrights ?? [];
|
|
let renderedLicense = licenseText;
|
|
if (copyrights.length > 0) {
|
|
const placeholderPattern = /^Copyright[^\n]*(?:<year>|<copyright holders>|<owner>)[^\n]*$/m;
|
|
if (placeholderPattern.test(renderedLicense)) {
|
|
renderedLicense = renderedLicense.replace(placeholderPattern, copyrights.join("\n"));
|
|
} else if (config.licenseId === "ISC") {
|
|
renderedLicense = renderedLicense.replace(
|
|
/^(?:Copyright[^\n]*\n)+/m,
|
|
`${copyrights.join("\n")}\n`,
|
|
);
|
|
} else {
|
|
renderedLicense = `${copyrights.join("\n")}\n\n${renderedLicense}`;
|
|
}
|
|
}
|
|
return [...(config.preamble ?? []), renderedLicense].join("\n\n").trim();
|
|
}
|
|
|
|
async function generatedNoticeText(
|
|
configs: ReadonlyArray<GeneratedNoticeConfigEntry>,
|
|
configDirectory: string,
|
|
allowMissing: boolean,
|
|
): Promise<string | null> {
|
|
const sections = await Promise.all(
|
|
configs.map(async (config) => {
|
|
const details = await resolveSpdxLicense(configDirectory, config.licenseId, allowMissing);
|
|
return details ? renderGeneratedNotice(config, details.licenseText) : null;
|
|
}),
|
|
);
|
|
return sections.some((section) => section === null)
|
|
? null
|
|
: (sections as ReadonlyArray<string>).join("\n\n---\n\n");
|
|
}
|
|
|
|
function configuredGeneratedNotices(
|
|
config: ThirdPartyLicensesConfig,
|
|
): ReadonlyArray<GeneratedNoticeConfigEntry> {
|
|
return [
|
|
...config.customNotices.flatMap((notice) => notice.generatedNotices ?? []),
|
|
...config.packageOverrides.flatMap((override) => override.generatedNotices ?? []),
|
|
];
|
|
}
|
|
|
|
async function syncConfiguredGeneratedNotices(
|
|
config: ThirdPartyLicensesConfig,
|
|
directory: string,
|
|
): Promise<void> {
|
|
const licenseIds = [
|
|
...new Set(configuredGeneratedNotices(config).map((notice) => notice.licenseId)),
|
|
].sort((left, right) => left.localeCompare(right));
|
|
await Promise.all(licenseIds.map((licenseId) => resolveSpdxLicense(directory, licenseId, false)));
|
|
}
|
|
|
|
export async function syncThirdPartyLicenseNotices(configFile: string | URL): Promise<void> {
|
|
const { config, directory } = await readConfig(configFile);
|
|
await syncConfiguredGeneratedNotices(config, directory);
|
|
}
|
|
|
|
async function readPackageJson(packageJsonPath: string): Promise<PackageJson> {
|
|
const source = await NodeFSP.readFile(packageJsonPath, "utf8");
|
|
const value = JSON.parse(source) as unknown;
|
|
if (!isRecord(value)) throw new Error(`Package manifest is not an object: ${packageJsonPath}`);
|
|
return value as PackageJson;
|
|
}
|
|
|
|
function packageIdentity(packageJson: PackageJson, packageRoot: string): string {
|
|
const name =
|
|
typeof packageJson.name === "string" ? packageJson.name : NodePath.basename(packageRoot);
|
|
const version = typeof packageJson.version === "string" ? packageJson.version : "unknown";
|
|
return `${name}@${version}`;
|
|
}
|
|
|
|
async function findPackageRoot(
|
|
resolvedPath: string,
|
|
expectedName?: string,
|
|
): Promise<{ readonly packageJson: PackageJson; readonly packageRoot: string } | null> {
|
|
let current = NodePath.dirname(resolvedPath);
|
|
const root = NodePath.parse(current).root;
|
|
|
|
while (current !== root) {
|
|
const packageJsonPath = NodePath.join(current, "package.json");
|
|
try {
|
|
const packageJson = await readPackageJson(packageJsonPath);
|
|
const matchesExpectedPackage =
|
|
expectedName !== undefined
|
|
? packageJson.name === expectedName
|
|
: typeof packageJson.name === "string" && typeof packageJson.version === "string";
|
|
if (matchesExpectedPackage) {
|
|
return { packageJson, packageRoot: await NodeFSP.realpath(current) };
|
|
}
|
|
} catch (error) {
|
|
const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
|
|
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
}
|
|
current = NodePath.dirname(current);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function resolveDependencyPackage(
|
|
dependencyName: string,
|
|
fromPackageJsonPath: string,
|
|
): Promise<{ readonly packageJson: PackageJson; readonly packageRoot: string } | null> {
|
|
const requireFromPackage = NodeModule.createRequire(fromPackageJsonPath);
|
|
const candidates = [`${dependencyName}/package.json`, dependencyName];
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const resolved = requireFromPackage.resolve(candidate);
|
|
const found = await findPackageRoot(resolved, dependencyName);
|
|
if (found) return found;
|
|
} catch (error) {
|
|
const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
|
|
if (code !== "MODULE_NOT_FOUND" && code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error;
|
|
}
|
|
}
|
|
|
|
let current = NodePath.dirname(fromPackageJsonPath);
|
|
const root = NodePath.parse(current).root;
|
|
while (true) {
|
|
const packageRoot = NodePath.join(current, "node_modules", dependencyName);
|
|
try {
|
|
const packageJson = await readPackageJson(NodePath.join(packageRoot, "package.json"));
|
|
if (packageJson.name === dependencyName) {
|
|
return { packageJson, packageRoot: await NodeFSP.realpath(packageRoot) };
|
|
}
|
|
} catch (error) {
|
|
const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
|
|
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
}
|
|
if (current === root) break;
|
|
current = NodePath.dirname(current);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function dependencyNames(packageJson: PackageJson): ReadonlyArray<string> {
|
|
return [
|
|
...new Set([
|
|
...Object.keys(packageJson.dependencies ?? {}),
|
|
...Object.keys(packageJson.optionalDependencies ?? {}),
|
|
]),
|
|
].sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
async function collectProductionDependencyPackages(
|
|
packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>,
|
|
): Promise<PackageCollection> {
|
|
const collection: PackageCollection = {
|
|
byIdentity: new Map(),
|
|
};
|
|
const visited = new Set<string>();
|
|
|
|
const visitManifest = async (packageJsonPath: string, bundle: string): Promise<void> => {
|
|
const packageJson = await readPackageJson(packageJsonPath);
|
|
for (const dependencyName of dependencyNames(packageJson)) {
|
|
const resolved = await resolveDependencyPackage(dependencyName, packageJsonPath);
|
|
if (!resolved) continue;
|
|
const visitKey = `${bundle}:${resolved.packageRoot}`;
|
|
if (visited.has(visitKey)) continue;
|
|
visited.add(visitKey);
|
|
|
|
const dependencyPackageJsonPath = NodePath.join(resolved.packageRoot, "package.json");
|
|
const name =
|
|
typeof resolved.packageJson.name === "string" ? resolved.packageJson.name : dependencyName;
|
|
if (!name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) {
|
|
const identity = packageIdentity(resolved.packageJson, resolved.packageRoot);
|
|
const existing = collection.byIdentity.get(identity);
|
|
if (existing) {
|
|
existing.bundles.add(bundle);
|
|
} else {
|
|
collection.byIdentity.set(identity, {
|
|
bundles: new Set([bundle]),
|
|
packageJson: resolved.packageJson,
|
|
packageRoot: resolved.packageRoot,
|
|
});
|
|
}
|
|
}
|
|
|
|
await visitManifest(dependencyPackageJsonPath, bundle);
|
|
}
|
|
};
|
|
|
|
for (const manifest of packageManifests) {
|
|
await visitManifest(asPath(manifest.path), manifest.bundle);
|
|
}
|
|
return collection;
|
|
}
|
|
|
|
function moduleFilePath(moduleId: string): string | null {
|
|
if (moduleId.startsWith("\0") || moduleId.includes("\0")) return null;
|
|
const withoutQuery = moduleId.split(/[?#]/, 1)[0] ?? moduleId;
|
|
const viteFilePath = withoutQuery.startsWith("/@fs/")
|
|
? withoutQuery.slice("/@fs/".length)
|
|
: withoutQuery;
|
|
const filePath = withoutQuery.startsWith("file:")
|
|
? NodeURL.fileURLToPath(withoutQuery)
|
|
: /^[A-Za-z]:[\\/]/.test(viteFilePath)
|
|
? viteFilePath
|
|
: NodePath.resolve("/", viteFilePath);
|
|
const normalized = filePath.replaceAll("\\", "/");
|
|
return normalized.includes("/node_modules/") ? filePath : null;
|
|
}
|
|
|
|
async function addBundledModulePackages(
|
|
collection: PackageCollection,
|
|
moduleIds: ReadonlyArray<string>,
|
|
bundle: string,
|
|
): Promise<void> {
|
|
for (const moduleId of moduleIds) {
|
|
const filePath = moduleFilePath(moduleId);
|
|
if (!filePath) continue;
|
|
let found: Awaited<ReturnType<typeof findPackageRoot>>;
|
|
try {
|
|
found = await findPackageRoot(await NodeFSP.realpath(filePath));
|
|
} catch (error) {
|
|
const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
|
|
if (code === "ENOENT" || code === "ENOTDIR") continue;
|
|
throw error;
|
|
}
|
|
if (!found || typeof found.packageJson.name !== "string") continue;
|
|
if (found.packageJson.name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) continue;
|
|
const identity = packageIdentity(found.packageJson, found.packageRoot);
|
|
const existing = collection.byIdentity.get(identity);
|
|
if (existing) {
|
|
existing.bundles.add(bundle);
|
|
} else {
|
|
collection.byIdentity.set(identity, {
|
|
bundles: new Set([bundle]),
|
|
packageJson: found.packageJson,
|
|
packageRoot: found.packageRoot,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeLicense(packageJson: PackageJson): string | null {
|
|
if (typeof packageJson.license === "string" && packageJson.license.trim().length > 0) {
|
|
return packageJson.license.trim();
|
|
}
|
|
if (isRecord(packageJson.license) && typeof packageJson.license.type === "string") {
|
|
return packageJson.license.type.trim() || null;
|
|
}
|
|
const declaredLicenses = Array.isArray(packageJson.license)
|
|
? packageJson.license
|
|
: packageJson.licenses;
|
|
if (Array.isArray(declaredLicenses)) {
|
|
const licenses = declaredLicenses
|
|
.map((entry) => {
|
|
if (typeof entry === "string") return entry.trim();
|
|
if (isRecord(entry) && typeof entry.type === "string") return entry.type.trim();
|
|
return "";
|
|
})
|
|
.filter((entry) => entry.length > 0);
|
|
if (licenses.length > 0) return licenses.join(" OR ");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeRepositoryUrl(value: unknown): string | null {
|
|
const raw =
|
|
typeof value === "string"
|
|
? value
|
|
: isRecord(value) && typeof value.url === "string"
|
|
? value.url
|
|
: null;
|
|
if (!raw) return null;
|
|
const trimmed = raw.trim();
|
|
if (trimmed.startsWith("github:")) return `https://github.com/${trimmed.slice(7)}`;
|
|
const normalized = trimmed
|
|
.replace(/^git\+ssh:\/\/git@github\.com\//, "https://github.com/")
|
|
.replace(/^git\+/, "")
|
|
.replace(/^git@github\.com:/, "https://github.com/")
|
|
.replace(/^ssh:\/\/(?:git@)?github\.com\//, "https://github.com/")
|
|
.replace(/^git:\/\/github\.com\//, "https://github.com/")
|
|
.replace(/#.*$/, "")
|
|
.replace(/\.git$/, "");
|
|
if (/^[\w.-]+\/[\w.-]+$/.test(normalized)) return `https://github.com/${normalized}`;
|
|
return normalized;
|
|
}
|
|
|
|
function packageSourceUrl(packageJson: PackageJson): string | null {
|
|
if (typeof packageJson.homepage === "string" && packageJson.homepage.trim().length > 0) {
|
|
return packageJson.homepage.trim();
|
|
}
|
|
return normalizeRepositoryUrl(packageJson.repository);
|
|
}
|
|
|
|
async function readPackageNoticeText(packageRoot: string): Promise<string | null> {
|
|
const noticeFiles: string[] = [];
|
|
const rootEntries = await NodeFSP.readdir(packageRoot, { withFileTypes: true });
|
|
noticeFiles.push(
|
|
...rootEntries
|
|
.filter((entry) => entry.isFile() && isNoticeTextFile(entry.name))
|
|
.map((entry) => entry.name),
|
|
);
|
|
|
|
const collectNestedNoticeFiles = async (directory: string, depth: number): Promise<void> => {
|
|
const directoryEntries = await NodeFSP.readdir(NodePath.join(packageRoot, directory), {
|
|
withFileTypes: true,
|
|
});
|
|
await Promise.all(
|
|
directoryEntries.map(async (entry) => {
|
|
const relativePath = NodePath.join(directory, entry.name);
|
|
if (entry.isFile() && isNoticeTextFile(entry.name)) {
|
|
noticeFiles.push(relativePath);
|
|
return;
|
|
}
|
|
if (
|
|
depth > 0 &&
|
|
entry.isDirectory() &&
|
|
entry.name !== "node_modules" &&
|
|
entry.name !== ".git"
|
|
) {
|
|
await collectNestedNoticeFiles(relativePath, depth - 1);
|
|
}
|
|
}),
|
|
);
|
|
};
|
|
await Promise.all(
|
|
rootEntries
|
|
.filter(
|
|
(entry) => entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git",
|
|
)
|
|
.map((entry) => collectNestedNoticeFiles(entry.name, 2)),
|
|
);
|
|
noticeFiles.sort((left, right) => left.localeCompare(right));
|
|
if (noticeFiles.length === 0) return null;
|
|
|
|
const sections: string[] = [];
|
|
for (const fileName of noticeFiles) {
|
|
const contents = (await NodeFSP.readFile(NodePath.join(packageRoot, fileName), "utf8")).trim();
|
|
if (contents.length === 0) continue;
|
|
sections.push(noticeFiles.length === 1 ? contents : `${fileName}\n\n${contents}`);
|
|
}
|
|
return sections.length > 0 ? sections.join("\n\n---\n\n") : null;
|
|
}
|
|
|
|
function repositoryNoticeKey(packageJson: PackageJson, license: string): string | null {
|
|
const repositoryUrl = normalizeRepositoryUrl(packageJson.repository);
|
|
return repositoryUrl ? `${repositoryUrl.toLowerCase()}\n${license.toLowerCase()}` : null;
|
|
}
|
|
|
|
async function collectRepositoryNotices(
|
|
collection: PackageCollection,
|
|
packageNotices: Map<string, Promise<string | null>>,
|
|
): Promise<ReadonlyMap<string, string>> {
|
|
const notices = new Map<string, string>();
|
|
const candidates = await Promise.all(
|
|
[...collection.byIdentity.values()].map(async (collected) => {
|
|
const license = normalizeLicense(collected.packageJson);
|
|
if (!license) return null;
|
|
const key = repositoryNoticeKey(collected.packageJson, license);
|
|
if (!key) return null;
|
|
const noticeText = await packageNoticeText(collected.packageRoot, packageNotices);
|
|
return noticeText ? { key, noticeText } : null;
|
|
}),
|
|
);
|
|
for (const candidate of candidates) {
|
|
if (candidate && !notices.has(candidate.key)) notices.set(candidate.key, candidate.noticeText);
|
|
}
|
|
return notices;
|
|
}
|
|
|
|
function packageNoticeText(
|
|
packageRoot: string,
|
|
cache: Map<string, Promise<string | null>>,
|
|
): Promise<string | null> {
|
|
const existing = cache.get(packageRoot);
|
|
if (existing) return existing;
|
|
const notice = readPackageNoticeText(packageRoot);
|
|
cache.set(packageRoot, notice);
|
|
return notice;
|
|
}
|
|
|
|
function findPackageOverride(
|
|
overrides: ReadonlyArray<PackageNoticeOverrideConfigEntry>,
|
|
name: string,
|
|
version: string,
|
|
packageJson: PackageJson,
|
|
): PackageNoticeOverrideConfigEntry | undefined {
|
|
const repositoryUrl = normalizeRepositoryUrl(packageJson.repository)?.toLowerCase();
|
|
const matchesRepository = (override: PackageNoticeOverrideConfigEntry) =>
|
|
repositoryUrl !== undefined &&
|
|
override.repositoryUrl !== undefined &&
|
|
normalizeRepositoryUrl(override.repositoryUrl)?.toLowerCase() === repositoryUrl;
|
|
return (
|
|
overrides.find((override) => override.name === name && override.version === version) ??
|
|
overrides.find((override) => override.name === name && override.version === undefined) ??
|
|
overrides.find((override) => matchesRepository(override) && override.version === version) ??
|
|
overrides.find((override) => matchesRepository(override) && override.version === undefined)
|
|
);
|
|
}
|
|
|
|
async function packageEntry(
|
|
collected: CollectedPackage,
|
|
config: ThirdPartyLicensesConfig,
|
|
configDirectory: string,
|
|
packageNotices: Map<string, Promise<string | null>>,
|
|
repositoryNotices: ReadonlyMap<string, string>,
|
|
allowMissingGeneratedNotices: boolean,
|
|
): Promise<ThirdPartyLicenseEntry | null> {
|
|
const name =
|
|
typeof collected.packageJson.name === "string"
|
|
? collected.packageJson.name
|
|
: NodePath.basename(collected.packageRoot);
|
|
const version =
|
|
typeof collected.packageJson.version === "string" ? collected.packageJson.version : "unknown";
|
|
const override = findPackageOverride(
|
|
config.packageOverrides,
|
|
name,
|
|
version,
|
|
collected.packageJson,
|
|
);
|
|
const license = override?.license ?? normalizeLicense(collected.packageJson);
|
|
if (!license || /^(?:unlicensed|proprietary)$/i.test(license)) {
|
|
throw new Error(
|
|
`${name}@${version} does not declare a distributable license. Add a package override in the third-party license config if the package publishes its notice elsewhere.`,
|
|
);
|
|
}
|
|
|
|
const repositoryKey = repositoryNoticeKey(collected.packageJson, license);
|
|
const noticeText = override?.generatedNotices
|
|
? await generatedNoticeText(
|
|
override.generatedNotices,
|
|
configDirectory,
|
|
allowMissingGeneratedNotices,
|
|
)
|
|
: override?.noticeFile
|
|
? (
|
|
await NodeFSP.readFile(NodePath.resolve(configDirectory, override.noticeFile), "utf8")
|
|
).trim()
|
|
: ((await packageNoticeText(collected.packageRoot, packageNotices)) ??
|
|
(repositoryKey ? repositoryNotices.get(repositoryKey) : undefined));
|
|
if (!noticeText) {
|
|
if (override?.generatedNotices && allowMissingGeneratedNotices) return null;
|
|
throw new Error(
|
|
`${name}@${version} does not include a license or notice file. Add a package override with "noticeFile" or "generatedNotice" in the third-party license config.`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
bundles: [...collected.bundles].sort((left, right) => left.localeCompare(right)),
|
|
kind: "package",
|
|
license,
|
|
name,
|
|
noticeText,
|
|
sourceUrl: override?.sourceUrl ?? packageSourceUrl(collected.packageJson),
|
|
version,
|
|
};
|
|
}
|
|
|
|
async function customEntries(
|
|
config: ThirdPartyLicensesConfig,
|
|
configDirectory: string,
|
|
includedBundles: ReadonlySet<string>,
|
|
allowMissingGeneratedNotices: boolean,
|
|
): Promise<ReadonlyArray<ThirdPartyLicenseEntry>> {
|
|
const entries = await Promise.all(
|
|
config.customNotices
|
|
.filter(
|
|
(notice) =>
|
|
(notice.includeInBundles ?? notice.bundles) === undefined ||
|
|
(notice.includeInBundles ?? notice.bundles)?.some((bundle) =>
|
|
includedBundles.has(bundle),
|
|
),
|
|
)
|
|
.map(async (notice) => {
|
|
const noticeText = notice.generatedNotices
|
|
? await generatedNoticeText(
|
|
notice.generatedNotices,
|
|
configDirectory,
|
|
allowMissingGeneratedNotices,
|
|
)
|
|
: (
|
|
await Promise.all(
|
|
notice.noticeFiles!.map(async (noticeFile) => {
|
|
const contents = (
|
|
await NodeFSP.readFile(NodePath.resolve(configDirectory, noticeFile), "utf8")
|
|
).trim();
|
|
if (contents.length === 0) {
|
|
throw new Error(`Custom third-party notice "${notice.name}" is empty.`);
|
|
}
|
|
return contents;
|
|
}),
|
|
)
|
|
).join("\n\n---\n\n");
|
|
if (noticeText === null) {
|
|
if (allowMissingGeneratedNotices) return null;
|
|
throw new Error(`Could not generate custom third-party notice "${notice.name}".`);
|
|
}
|
|
if (noticeText.length === 0) {
|
|
throw new Error(`Custom third-party notice "${notice.name}" is empty.`);
|
|
}
|
|
return {
|
|
bundles: [...(notice.bundles ?? ["assets"])].sort((left, right) =>
|
|
left.localeCompare(right),
|
|
),
|
|
kind: "custom" as const,
|
|
license: notice.license,
|
|
name: notice.name,
|
|
noticeText,
|
|
sourceUrl: notice.sourceUrl ?? null,
|
|
version: notice.version ?? null,
|
|
};
|
|
}),
|
|
);
|
|
return entries.flatMap((entry) => (entry ? [entry] : []));
|
|
}
|
|
|
|
function entrySort(left: ThirdPartyLicenseEntry, right: ThirdPartyLicenseEntry): number {
|
|
return (
|
|
left.name.localeCompare(right.name) ||
|
|
(left.version ?? "").localeCompare(right.version ?? "") ||
|
|
left.kind.localeCompare(right.kind)
|
|
);
|
|
}
|
|
|
|
function assertUniqueEntries(entries: ReadonlyArray<ThirdPartyLicenseEntry>): void {
|
|
const identities = new Set<string>();
|
|
for (const entry of entries) {
|
|
const identity = JSON.stringify([entry.kind, entry.name, entry.version]);
|
|
if (identities.has(identity)) {
|
|
throw new Error(
|
|
`Third-party license generation found a duplicate ${entry.kind} notice for ${entry.name}${entry.version ? `@${entry.version}` : ""}.`,
|
|
);
|
|
}
|
|
identities.add(identity);
|
|
}
|
|
}
|
|
|
|
export async function generateThirdPartyLicenseManifest(input: {
|
|
readonly configFile?: string | URL;
|
|
readonly packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>;
|
|
readonly bundledModuleIds?: ReadonlyArray<string>;
|
|
readonly bundleName?: string;
|
|
readonly allowMissingGeneratedNotices?: boolean;
|
|
}): Promise<ThirdPartyLicenseManifest> {
|
|
const [{ config, directory }, collection] = await Promise.all([
|
|
readConfig(input.configFile),
|
|
collectProductionDependencyPackages(input.packageManifests),
|
|
]);
|
|
if (!(input.allowMissingGeneratedNotices ?? false)) {
|
|
await syncConfiguredGeneratedNotices(config, directory);
|
|
}
|
|
if (input.bundledModuleIds && input.bundleName) {
|
|
await addBundledModulePackages(collection, input.bundledModuleIds, input.bundleName);
|
|
}
|
|
|
|
const packageNotices = new Map<string, Promise<string | null>>();
|
|
const repositoryNotices = await collectRepositoryNotices(collection, packageNotices);
|
|
|
|
const packageEntryResults = await Promise.allSettled(
|
|
[...collection.byIdentity.values()].map((collected) =>
|
|
packageEntry(
|
|
collected,
|
|
config,
|
|
directory,
|
|
packageNotices,
|
|
repositoryNotices,
|
|
input.allowMissingGeneratedNotices ?? false,
|
|
),
|
|
),
|
|
);
|
|
const failures = packageEntryResults.flatMap((result) =>
|
|
result.status === "rejected"
|
|
? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
|
|
: [],
|
|
);
|
|
if (failures.length > 0) {
|
|
throw new Error(
|
|
`Third-party license generation found ${String(failures.length)} invalid package notice${failures.length === 1 ? "" : "s"}:\n${failures.map((failure) => `- ${failure}`).join("\n")}`,
|
|
);
|
|
}
|
|
const packageEntries = packageEntryResults.flatMap((result) =>
|
|
result.status === "fulfilled" && result.value ? [result.value] : [],
|
|
);
|
|
const includedBundles = new Set(input.packageManifests.map((manifest) => manifest.bundle));
|
|
if (input.bundleName) includedBundles.add(input.bundleName);
|
|
const manualEntries = await customEntries(
|
|
config,
|
|
directory,
|
|
includedBundles,
|
|
input.allowMissingGeneratedNotices ?? false,
|
|
);
|
|
const entries = [...packageEntries, ...manualEntries].sort(entrySort);
|
|
assertUniqueEntries(entries);
|
|
return {
|
|
schemaVersion: 1,
|
|
entries,
|
|
};
|
|
}
|
|
|
|
function moduleIdsFromBundle(bundle: unknown): ReadonlyArray<string> {
|
|
const ids = new Set<string>();
|
|
if (!isRecord(bundle)) return [];
|
|
for (const output of Object.values(bundle)) {
|
|
if (!isRecord(output) || output.type !== "chunk" || !isRecord(output.modules)) continue;
|
|
for (const id of Object.keys(output.modules)) ids.add(id);
|
|
}
|
|
return [...ids];
|
|
}
|
|
|
|
function serializeManifest(manifest: ThirdPartyLicenseManifest): string {
|
|
return `${JSON.stringify(manifest)}\n`;
|
|
}
|
|
|
|
export function thirdPartyLicensesPlugin(options: ThirdPartyLicensesPluginOptions): Plugin {
|
|
return {
|
|
name: "t3code:third-party-licenses",
|
|
configureServer(server) {
|
|
let manifestPromise: Promise<ThirdPartyLicenseManifest> | null = null;
|
|
server.middlewares.use((request, response, next) => {
|
|
if (request.url?.split("?", 1)[0] !== `/${THIRD_PARTY_LICENSES_FILE_NAME}`) {
|
|
next();
|
|
return;
|
|
}
|
|
manifestPromise ??= generateThirdPartyLicenseManifest({
|
|
packageManifests: options.packageManifests,
|
|
bundleName: options.bundleName,
|
|
allowMissingGeneratedNotices: true,
|
|
...(options.configFile !== undefined ? { configFile: options.configFile } : {}),
|
|
}).catch((error: unknown) => {
|
|
manifestPromise = null;
|
|
throw error;
|
|
});
|
|
void manifestPromise.then(
|
|
(manifest) => {
|
|
response.statusCode = 200;
|
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
response.setHeader("Cache-Control", "no-store");
|
|
response.end(serializeManifest(manifest));
|
|
},
|
|
(error: unknown) => {
|
|
next(error instanceof Error ? error : new Error(String(error)));
|
|
},
|
|
);
|
|
});
|
|
},
|
|
async generateBundle(_outputOptions, bundle) {
|
|
const manifest = await generateThirdPartyLicenseManifest({
|
|
packageManifests: options.packageManifests,
|
|
bundledModuleIds: moduleIdsFromBundle(bundle),
|
|
bundleName: options.bundleName,
|
|
...(options.configFile !== undefined ? { configFile: options.configFile } : {}),
|
|
});
|
|
this.emitFile({
|
|
type: "asset",
|
|
fileName: THIRD_PARTY_LICENSES_FILE_NAME,
|
|
source: serializeManifest(manifest),
|
|
});
|
|
},
|
|
};
|
|
}
|