1// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Vite's build plugin runs before an Effect runtime exists.
2
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6import * as NodeModule from "node:module";
7
8import type { Plugin } from "vite-plus";
9
10export const THIRD_PARTY_LICENSES_FILE_NAME = "third-party-licenses.json";
11const SPDX_LICENSE_LIST_VERSION = "v3.28.0";
12const SPDX_LICENSE_LIST_REVISION = "c4a7237ec8f4654e867546f9f409749300f1bf4c";
13const GENERATED_NOTICE_CACHE_DIRECTORY = ".generated/third-party-licenses/spdx";
14
15export interface ThirdPartyLicenseEntry {
16 readonly bundles: ReadonlyArray<string>;
17 readonly kind: "custom" | "package";
18 readonly license: string;
19 readonly name: string;
20 readonly noticeText: string;
21 readonly sourceUrl: string | null;
22 readonly version: string | null;
23}
24
25export interface ThirdPartyLicenseManifest {
26 readonly schemaVersion: 1;
27 readonly entries: ReadonlyArray<ThirdPartyLicenseEntry>;
28}
29
30export interface ThirdPartyLicensePackageManifest {
31 readonly bundle: string;
32 readonly path: string | URL;
33}
34
35export interface ThirdPartyLicensesPluginOptions {
36 readonly configFile?: string | URL;
37 readonly packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>;
38 readonly bundleName: string;
39}
40
41interface GeneratedNoticeConfigEntry {
42 readonly copyrights?: ReadonlyArray<string>;
43 readonly licenseId: string;
44 readonly preamble?: ReadonlyArray<string>;
45}
46
47interface PackageJson {
48 readonly dependencies?: Readonly<Record<string, string>>;
49 readonly homepage?: unknown;
50 readonly license?: unknown;
51 readonly licenses?: unknown;
52 readonly name?: unknown;
53 readonly optionalDependencies?: Readonly<Record<string, string>>;
54 readonly repository?: unknown;
55 readonly version?: unknown;
56}
57
58interface CustomNoticeConfigEntry {
59 readonly bundles?: ReadonlyArray<string>;
60 readonly includeInBundles?: ReadonlyArray<string>;
61 readonly license: string;
62 readonly name: string;
63 readonly generatedNotices?: ReadonlyArray<GeneratedNoticeConfigEntry>;
64 readonly noticeFiles?: ReadonlyArray<string>;
65 readonly sourceUrl?: string;
66 readonly version?: string;
67}
68
69interface PackageNoticeOverrideConfigEntry {
70 readonly generatedNotices?: ReadonlyArray<GeneratedNoticeConfigEntry>;
71 readonly license?: string;
72 readonly name?: string;
73 readonly noticeFile?: string;
74 readonly repositoryUrl?: string;
75 readonly sourceUrl?: string;
76 readonly version?: string;
77}
78
79interface ThirdPartyLicensesConfig {
80 readonly customNotices: ReadonlyArray<CustomNoticeConfigEntry>;
81 readonly packageOverrides: ReadonlyArray<PackageNoticeOverrideConfigEntry>;
82}
83
84interface SpdxLicenseDetails {
85 readonly licenseId: string;
86 readonly licenseText: string;
87}
88
89interface CollectedPackage {
90 readonly bundles: Set<string>;
91 readonly packageJson: PackageJson;
92 readonly packageRoot: string;
93}
94
95interface PackageCollection {
96 readonly byIdentity: Map<string, CollectedPackage>;
97}
98
99const EMPTY_CONFIG: ThirdPartyLicensesConfig = {
100 customNotices: [],
101 packageOverrides: [],
102};
103
104const NOTICE_FILE_PATTERN = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/i;
105const NOTICE_TEXT_EXTENSIONS = new Set([
106 "",
107 ".0bsd",
108 ".agpl",
109 ".apache2",
110 ".bsd",
111 ".gpl",
112 ".isc",
113 ".lgpl",
114 ".markdown",
115 ".md",
116 ".mit",
117 ".mpl",
118 ".mpl2",
119 ".rst",
120 ".txt",
121 ".unlicense",
122]);
123const FIRST_PARTY_PACKAGE_PREFIX = "@t3tools/";
124
125function isNoticeTextFile(fileName: string): boolean {
126 return (
127 NOTICE_FILE_PATTERN.test(fileName) &&
128 NOTICE_TEXT_EXTENSIONS.has(NodePath.extname(fileName).toLowerCase())
129 );
130}
131
132function asPath(value: string | URL): string {
133 return value instanceof URL ? NodeURL.fileURLToPath(value) : NodePath.resolve(value);
134}
135
136function isRecord(value: unknown): value is Record<string, unknown> {
137 return typeof value === "object" && value !== null && !Array.isArray(value);
138}
139
140function readRequiredString(value: Record<string, unknown>, key: string, context: string): string {
141 const field = value[key];
142 if (typeof field !== "string" || field.trim().length === 0) {
143 throw new Error(`${context} must define a non-empty "${key}" string.`);
144 }
145 return field.trim();
146}
147
148function readOptionalString(
149 value: Record<string, unknown>,
150 key: string,
151 context: string,
152): string | undefined {
153 const field = value[key];
154 if (field === undefined) return undefined;
155 if (typeof field !== "string" || field.trim().length === 0) {
156 throw new Error(`${context} must define "${key}" as a non-empty string when present.`);
157 }
158 return field.trim();
159}
160
161function readOptionalStringArray(
162 value: Record<string, unknown>,
163 key: string,
164 context: string,
165): ReadonlyArray<string> | undefined {
166 const field = value[key];
167 if (field === undefined) return undefined;
168 if (
169 !Array.isArray(field) ||
170 field.length === 0 ||
171 field.some((entry) => typeof entry !== "string" || entry.trim().length === 0)
172 ) {
173 throw new Error(`${context} must define "${key}" as a non-empty string array.`);
174 }
175 return field.map((entry) => (entry as string).trim());
176}
177
178function decodeGeneratedNotice(value: unknown, context: string): GeneratedNoticeConfigEntry {
179 if (!isRecord(value)) throw new Error(`${context} must be an object.`);
180 const copyrights = readOptionalStringArray(value, "copyrights", context);
181 const preamble = readOptionalStringArray(value, "preamble", context);
182 return {
183 licenseId: readRequiredString(value, "licenseId", context),
184 ...(copyrights !== undefined ? { copyrights } : {}),
185 ...(preamble !== undefined ? { preamble } : {}),
186 };
187}
188
189function decodeGeneratedNotices(
190 value: unknown,
191 context: string,
192): ReadonlyArray<GeneratedNoticeConfigEntry> | undefined {
193 if (value === undefined) return undefined;
194 if (!Array.isArray(value) || value.length === 0) {
195 throw new Error(`${context} must define "generatedNotices" as a non-empty array.`);
196 }
197 return value.map((entry, index) =>
198 decodeGeneratedNotice(entry, `${context} generated notice at index ${String(index)}`),
199 );
200}
201
202function decodeCustomNotices(value: unknown): ReadonlyArray<CustomNoticeConfigEntry> {
203 if (value === undefined) return [];
204 if (!Array.isArray(value)) {
205 throw new Error('Third-party license config field "customNotices" must be an array.');
206 }
207 return value.map((entry, index) => {
208 const context = `Third-party custom notice at index ${String(index)}`;
209 if (!isRecord(entry)) throw new Error(`${context} must be an object.`);
210 const version = readOptionalString(entry, "version", context);
211 const sourceUrl = readOptionalString(entry, "sourceUrl", context);
212 const bundles = readOptionalStringArray(entry, "bundles", context);
213 const includeInBundles = readOptionalStringArray(entry, "includeInBundles", context);
214 const noticeFile = readOptionalString(entry, "noticeFile", context);
215 const noticeFiles = readOptionalStringArray(entry, "noticeFiles", context);
216 const generatedNotices = decodeGeneratedNotices(entry.generatedNotices, context);
217 const noticeSourceCount =
218 Number(noticeFile !== undefined) +
219 Number(noticeFiles !== undefined) +
220 Number(generatedNotices !== undefined);
221 if (noticeSourceCount !== 1) {
222 throw new Error(
223 `${context} must define exactly one of "noticeFile", "noticeFiles", or "generatedNotices".`,
224 );
225 }
226 return {
227 name: readRequiredString(entry, "name", context),
228 license: readRequiredString(entry, "license", context),
229 ...(noticeFile !== undefined ? { noticeFiles: [noticeFile] } : {}),
230 ...(noticeFiles !== undefined ? { noticeFiles } : {}),
231 ...(generatedNotices !== undefined ? { generatedNotices } : {}),
232 ...(version !== undefined ? { version } : {}),
233 ...(sourceUrl !== undefined ? { sourceUrl } : {}),
234 ...(bundles !== undefined ? { bundles } : {}),
235 ...(includeInBundles !== undefined ? { includeInBundles } : {}),
236 };
237 });
238}
239
240function decodePackageOverrides(value: unknown): ReadonlyArray<PackageNoticeOverrideConfigEntry> {
241 if (value === undefined) return [];
242 if (!Array.isArray(value)) {
243 throw new Error('Third-party license config field "packageOverrides" must be an array.');
244 }
245 return value.map((entry, index) => {
246 const context = `Third-party package override at index ${String(index)}`;
247 if (!isRecord(entry)) throw new Error(`${context} must be an object.`);
248 const name = readOptionalString(entry, "name", context);
249 const repositoryUrl = readOptionalString(entry, "repositoryUrl", context);
250 if ((name === undefined) === (repositoryUrl === undefined)) {
251 throw new Error(`${context} must define exactly one of "name" or "repositoryUrl".`);
252 }
253 const version = readOptionalString(entry, "version", context);
254 const license = readOptionalString(entry, "license", context);
255 const noticeFile = readOptionalString(entry, "noticeFile", context);
256 const generatedNotice =
257 entry.generatedNotice === undefined
258 ? undefined
259 : decodeGeneratedNotice(entry.generatedNotice, `${context} generated notice`);
260 const generatedNotices = decodeGeneratedNotices(entry.generatedNotices, context);
261 if (
262 Number(noticeFile !== undefined) +
263 Number(generatedNotice !== undefined) +
264 Number(generatedNotices !== undefined) >
265 1
266 ) {
267 throw new Error(
268 `${context} can define only one of "noticeFile", "generatedNotice", or "generatedNotices".`,
269 );
270 }
271 const sourceUrl = readOptionalString(entry, "sourceUrl", context);
272 return {
273 ...(name !== undefined ? { name } : {}),
274 ...(repositoryUrl !== undefined ? { repositoryUrl } : {}),
275 ...(version !== undefined ? { version } : {}),
276 ...(license !== undefined ? { license } : {}),
277 ...(noticeFile !== undefined ? { noticeFile } : {}),
278 ...(generatedNotice !== undefined ? { generatedNotices: [generatedNotice] } : {}),
279 ...(generatedNotices !== undefined ? { generatedNotices } : {}),
280 ...(sourceUrl !== undefined ? { sourceUrl } : {}),
281 };
282 });
283}
284
285async function readConfig(configFile: string | URL | undefined): Promise<{
286 readonly config: ThirdPartyLicensesConfig;
287 readonly directory: string;
288}> {
289 if (configFile === undefined) {
290 return { config: EMPTY_CONFIG, directory: NodePath.resolve(".") };
291 }
292 const configPath = asPath(configFile);
293 const source = await NodeFSP.readFile(configPath, "utf8");
294 const decoded = JSON.parse(source) as unknown;
295 if (!isRecord(decoded)) throw new Error("Third-party license config must contain an object.");
296 return {
297 config: {
298 customNotices: decodeCustomNotices(decoded.customNotices),
299 packageOverrides: decodePackageOverrides(decoded.packageOverrides),
300 },
301 directory: NodePath.dirname(configPath),
302 };
303}
304
305function spdxLicenseCachePath(configDirectory: string, licenseId: string): string {
306 return NodePath.join(
307 configDirectory,
308 GENERATED_NOTICE_CACHE_DIRECTORY,
309 SPDX_LICENSE_LIST_VERSION,
310 `${licenseId}.json`,
311 );
312}
313
314function decodeSpdxLicenseDetails(value: unknown, expectedLicenseId: string): SpdxLicenseDetails {
315 if (
316 !isRecord(value) ||
317 value.licenseId !== expectedLicenseId ||
318 typeof value.licenseText !== "string" ||
319 value.licenseText.trim().length === 0
320 ) {
321 throw new Error(`SPDX returned invalid license details for ${expectedLicenseId}.`);
322 }
323 return { licenseId: expectedLicenseId, licenseText: value.licenseText.trim() };
324}
325
326async function readCachedSpdxLicense(
327 configDirectory: string,
328 licenseId: string,
329): Promise<SpdxLicenseDetails | null> {
330 try {
331 const source = await NodeFSP.readFile(spdxLicenseCachePath(configDirectory, licenseId), "utf8");
332 return decodeSpdxLicenseDetails(JSON.parse(source) as unknown, licenseId);
333 } catch (error) {
334 const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
335 if (code === "ENOENT") return null;
336 throw error;
337 }
338}
339
340async function downloadSpdxLicense(
341 configDirectory: string,
342 licenseId: string,
343): Promise<SpdxLicenseDetails> {
344 const url = `https://raw.githubusercontent.com/spdx/license-list-data/${SPDX_LICENSE_LIST_REVISION}/json/details/${encodeURIComponent(licenseId)}.json`;
345 const response = await fetch(url);
346 if (!response.ok) {
347 throw new Error(
348 `Could not download SPDX license ${licenseId}: HTTP ${String(response.status)}.`,
349 );
350 }
351 const details = decodeSpdxLicenseDetails((await response.json()) as unknown, licenseId);
352 const cachePath = spdxLicenseCachePath(configDirectory, licenseId);
353 await NodeFSP.mkdir(NodePath.dirname(cachePath), { recursive: true });
354 await NodeFSP.writeFile(cachePath, `${JSON.stringify(details)}\n`, "utf8");
355 return details;
356}
357
358async function resolveSpdxLicense(
359 configDirectory: string,
360 licenseId: string,
361 allowMissing: boolean,
362): Promise<SpdxLicenseDetails | null> {
363 const cached = await readCachedSpdxLicense(configDirectory, licenseId);
364 if (cached || allowMissing) return cached;
365 return downloadSpdxLicense(configDirectory, licenseId);
366}
367
368function renderGeneratedNotice(config: GeneratedNoticeConfigEntry, licenseText: string): string {
369 const copyrights = config.copyrights ?? [];
370 let renderedLicense = licenseText;
371 if (copyrights.length > 0) {
372 const placeholderPattern = /^Copyright[^\n]*(?:<year>|<copyright holders>|<owner>)[^\n]*$/m;
373 if (placeholderPattern.test(renderedLicense)) {
374 renderedLicense = renderedLicense.replace(placeholderPattern, copyrights.join("\n"));
375 } else if (config.licenseId === "ISC") {
376 renderedLicense = renderedLicense.replace(
377 /^(?:Copyright[^\n]*\n)+/m,
378 `${copyrights.join("\n")}\n`,
379 );
380 } else {
381 renderedLicense = `${copyrights.join("\n")}\n\n${renderedLicense}`;
382 }
383 }
384 return [...(config.preamble ?? []), renderedLicense].join("\n\n").trim();
385}
386
387async function generatedNoticeText(
388 configs: ReadonlyArray<GeneratedNoticeConfigEntry>,
389 configDirectory: string,
390 allowMissing: boolean,
391): Promise<string | null> {
392 const sections = await Promise.all(
393 configs.map(async (config) => {
394 const details = await resolveSpdxLicense(configDirectory, config.licenseId, allowMissing);
395 return details ? renderGeneratedNotice(config, details.licenseText) : null;
396 }),
397 );
398 return sections.some((section) => section === null)
399 ? null
400 : (sections as ReadonlyArray<string>).join("\n\n---\n\n");
401}
402
403function configuredGeneratedNotices(
404 config: ThirdPartyLicensesConfig,
405): ReadonlyArray<GeneratedNoticeConfigEntry> {
406 return [
407 ...config.customNotices.flatMap((notice) => notice.generatedNotices ?? []),
408 ...config.packageOverrides.flatMap((override) => override.generatedNotices ?? []),
409 ];
410}
411
412async function syncConfiguredGeneratedNotices(
413 config: ThirdPartyLicensesConfig,
414 directory: string,
415): Promise<void> {
416 const licenseIds = [
417 ...new Set(configuredGeneratedNotices(config).map((notice) => notice.licenseId)),
418 ].sort((left, right) => left.localeCompare(right));
419 await Promise.all(licenseIds.map((licenseId) => resolveSpdxLicense(directory, licenseId, false)));
420}
421
422export async function syncThirdPartyLicenseNotices(configFile: string | URL): Promise<void> {
423 const { config, directory } = await readConfig(configFile);
424 await syncConfiguredGeneratedNotices(config, directory);
425}
426
427async function readPackageJson(packageJsonPath: string): Promise<PackageJson> {
428 const source = await NodeFSP.readFile(packageJsonPath, "utf8");
429 const value = JSON.parse(source) as unknown;
430 if (!isRecord(value)) throw new Error(`Package manifest is not an object: ${packageJsonPath}`);
431 return value as PackageJson;
432}
433
434function packageIdentity(packageJson: PackageJson, packageRoot: string): string {
435 const name =
436 typeof packageJson.name === "string" ? packageJson.name : NodePath.basename(packageRoot);
437 const version = typeof packageJson.version === "string" ? packageJson.version : "unknown";
438 return `${name}@${version}`;
439}
440
441async function findPackageRoot(
442 resolvedPath: string,
443 expectedName?: string,
444): Promise<{ readonly packageJson: PackageJson; readonly packageRoot: string } | null> {
445 let current = NodePath.dirname(resolvedPath);
446 const root = NodePath.parse(current).root;
447
448 while (current !== root) {
449 const packageJsonPath = NodePath.join(current, "package.json");
450 try {
451 const packageJson = await readPackageJson(packageJsonPath);
452 const matchesExpectedPackage =
453 expectedName !== undefined
454 ? packageJson.name === expectedName
455 : typeof packageJson.name === "string" && typeof packageJson.version === "string";
456 if (matchesExpectedPackage) {
457 return { packageJson, packageRoot: await NodeFSP.realpath(current) };
458 }
459 } catch (error) {
460 const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
461 if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
462 }
463 current = NodePath.dirname(current);
464 }
465 return null;
466}
467
468async function resolveDependencyPackage(
469 dependencyName: string,
470 fromPackageJsonPath: string,
471): Promise<{ readonly packageJson: PackageJson; readonly packageRoot: string } | null> {
472 const requireFromPackage = NodeModule.createRequire(fromPackageJsonPath);
473 const candidates = [`${dependencyName}/package.json`, dependencyName];
474 for (const candidate of candidates) {
475 try {
476 const resolved = requireFromPackage.resolve(candidate);
477 const found = await findPackageRoot(resolved, dependencyName);
478 if (found) return found;
479 } catch (error) {
480 const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
481 if (code !== "MODULE_NOT_FOUND" && code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error;
482 }
483 }
484
485 let current = NodePath.dirname(fromPackageJsonPath);
486 const root = NodePath.parse(current).root;
487 while (true) {
488 const packageRoot = NodePath.join(current, "node_modules", dependencyName);
489 try {
490 const packageJson = await readPackageJson(NodePath.join(packageRoot, "package.json"));
491 if (packageJson.name === dependencyName) {
492 return { packageJson, packageRoot: await NodeFSP.realpath(packageRoot) };
493 }
494 } catch (error) {
495 const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
496 if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
497 }
498 if (current === root) break;
499 current = NodePath.dirname(current);
500 }
501 return null;
502}
503
504function dependencyNames(packageJson: PackageJson): ReadonlyArray<string> {
505 return [
506 ...new Set([
507 ...Object.keys(packageJson.dependencies ?? {}),
508 ...Object.keys(packageJson.optionalDependencies ?? {}),
509 ]),
510 ].sort((left, right) => left.localeCompare(right));
511}
512
513async function collectProductionDependencyPackages(
514 packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>,
515): Promise<PackageCollection> {
516 const collection: PackageCollection = {
517 byIdentity: new Map(),
518 };
519 const visited = new Set<string>();
520
521 const visitManifest = async (packageJsonPath: string, bundle: string): Promise<void> => {
522 const packageJson = await readPackageJson(packageJsonPath);
523 for (const dependencyName of dependencyNames(packageJson)) {
524 const resolved = await resolveDependencyPackage(dependencyName, packageJsonPath);
525 if (!resolved) continue;
526 const visitKey = `${bundle}:${resolved.packageRoot}`;
527 if (visited.has(visitKey)) continue;
528 visited.add(visitKey);
529
530 const dependencyPackageJsonPath = NodePath.join(resolved.packageRoot, "package.json");
531 const name =
532 typeof resolved.packageJson.name === "string" ? resolved.packageJson.name : dependencyName;
533 if (!name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) {
534 const identity = packageIdentity(resolved.packageJson, resolved.packageRoot);
535 const existing = collection.byIdentity.get(identity);
536 if (existing) {
537 existing.bundles.add(bundle);
538 } else {
539 collection.byIdentity.set(identity, {
540 bundles: new Set([bundle]),
541 packageJson: resolved.packageJson,
542 packageRoot: resolved.packageRoot,
543 });
544 }
545 }
546
547 await visitManifest(dependencyPackageJsonPath, bundle);
548 }
549 };
550
551 for (const manifest of packageManifests) {
552 await visitManifest(asPath(manifest.path), manifest.bundle);
553 }
554 return collection;
555}
556
557function moduleFilePath(moduleId: string): string | null {
558 if (moduleId.startsWith("\0") || moduleId.includes("\0")) return null;
559 const withoutQuery = moduleId.split(/[?#]/, 1)[0] ?? moduleId;
560 const viteFilePath = withoutQuery.startsWith("/@fs/")
561 ? withoutQuery.slice("/@fs/".length)
562 : withoutQuery;
563 const filePath = withoutQuery.startsWith("file:")
564 ? NodeURL.fileURLToPath(withoutQuery)
565 : /^[A-Za-z]:[\\/]/.test(viteFilePath)
566 ? viteFilePath
567 : NodePath.resolve("/", viteFilePath);
568 const normalized = filePath.replaceAll("\\", "/");
569 return normalized.includes("/node_modules/") ? filePath : null;
570}
571
572async function addBundledModulePackages(
573 collection: PackageCollection,
574 moduleIds: ReadonlyArray<string>,
575 bundle: string,
576): Promise<void> {
577 for (const moduleId of moduleIds) {
578 const filePath = moduleFilePath(moduleId);
579 if (!filePath) continue;
580 let found: Awaited<ReturnType<typeof findPackageRoot>>;
581 try {
582 found = await findPackageRoot(await NodeFSP.realpath(filePath));
583 } catch (error) {
584 const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
585 if (code === "ENOENT" || code === "ENOTDIR") continue;
586 throw error;
587 }
588 if (!found || typeof found.packageJson.name !== "string") continue;
589 if (found.packageJson.name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) continue;
590 const identity = packageIdentity(found.packageJson, found.packageRoot);
591 const existing = collection.byIdentity.get(identity);
592 if (existing) {
593 existing.bundles.add(bundle);
594 } else {
595 collection.byIdentity.set(identity, {
596 bundles: new Set([bundle]),
597 packageJson: found.packageJson,
598 packageRoot: found.packageRoot,
599 });
600 }
601 }
602}
603
604function normalizeLicense(packageJson: PackageJson): string | null {
605 if (typeof packageJson.license === "string" && packageJson.license.trim().length > 0) {
606 return packageJson.license.trim();
607 }
608 if (isRecord(packageJson.license) && typeof packageJson.license.type === "string") {
609 return packageJson.license.type.trim() || null;
610 }
611 const declaredLicenses = Array.isArray(packageJson.license)
612 ? packageJson.license
613 : packageJson.licenses;
614 if (Array.isArray(declaredLicenses)) {
615 const licenses = declaredLicenses
616 .map((entry) => {
617 if (typeof entry === "string") return entry.trim();
618 if (isRecord(entry) && typeof entry.type === "string") return entry.type.trim();
619 return "";
620 })
621 .filter((entry) => entry.length > 0);
622 if (licenses.length > 0) return licenses.join(" OR ");
623 }
624 return null;
625}
626
627function normalizeRepositoryUrl(value: unknown): string | null {
628 const raw =
629 typeof value === "string"
630 ? value
631 : isRecord(value) && typeof value.url === "string"
632 ? value.url
633 : null;
634 if (!raw) return null;
635 const trimmed = raw.trim();
636 if (trimmed.startsWith("github:")) return `https://github.com/${trimmed.slice(7)}`;
637 const normalized = trimmed
638 .replace(/^git\+ssh:\/\/git@github\.com\//, "https://github.com/")
639 .replace(/^git\+/, "")
640 .replace(/^git@github\.com:/, "https://github.com/")
641 .replace(/^ssh:\/\/(?:git@)?github\.com\//, "https://github.com/")
642 .replace(/^git:\/\/github\.com\//, "https://github.com/")
643 .replace(/#.*$/, "")
644 .replace(/\.git$/, "");
645 if (/^[\w.-]+\/[\w.-]+$/.test(normalized)) return `https://github.com/${normalized}`;
646 return normalized;
647}
648
649function packageSourceUrl(packageJson: PackageJson): string | null {
650 if (typeof packageJson.homepage === "string" && packageJson.homepage.trim().length > 0) {
651 return packageJson.homepage.trim();
652 }
653 return normalizeRepositoryUrl(packageJson.repository);
654}
655
656async function readPackageNoticeText(packageRoot: string): Promise<string | null> {
657 const noticeFiles: string[] = [];
658 const rootEntries = await NodeFSP.readdir(packageRoot, { withFileTypes: true });
659 noticeFiles.push(
660 ...rootEntries
661 .filter((entry) => entry.isFile() && isNoticeTextFile(entry.name))
662 .map((entry) => entry.name),
663 );
664
665 const collectNestedNoticeFiles = async (directory: string, depth: number): Promise<void> => {
666 const directoryEntries = await NodeFSP.readdir(NodePath.join(packageRoot, directory), {
667 withFileTypes: true,
668 });
669 await Promise.all(
670 directoryEntries.map(async (entry) => {
671 const relativePath = NodePath.join(directory, entry.name);
672 if (entry.isFile() && isNoticeTextFile(entry.name)) {
673 noticeFiles.push(relativePath);
674 return;
675 }
676 if (
677 depth > 0 &&
678 entry.isDirectory() &&
679 entry.name !== "node_modules" &&
680 entry.name !== ".git"
681 ) {
682 await collectNestedNoticeFiles(relativePath, depth - 1);
683 }
684 }),
685 );
686 };
687 await Promise.all(
688 rootEntries
689 .filter(
690 (entry) => entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git",
691 )
692 .map((entry) => collectNestedNoticeFiles(entry.name, 2)),
693 );
694 noticeFiles.sort((left, right) => left.localeCompare(right));
695 if (noticeFiles.length === 0) return null;
696
697 const sections: string[] = [];
698 for (const fileName of noticeFiles) {
699 const contents = (await NodeFSP.readFile(NodePath.join(packageRoot, fileName), "utf8")).trim();
700 if (contents.length === 0) continue;
701 sections.push(noticeFiles.length === 1 ? contents : `${fileName}\n\n${contents}`);
702 }
703 return sections.length > 0 ? sections.join("\n\n---\n\n") : null;
704}
705
706function repositoryNoticeKey(packageJson: PackageJson, license: string): string | null {
707 const repositoryUrl = normalizeRepositoryUrl(packageJson.repository);
708 return repositoryUrl ? `${repositoryUrl.toLowerCase()}\n${license.toLowerCase()}` : null;
709}
710
711async function collectRepositoryNotices(
712 collection: PackageCollection,
713 packageNotices: Map<string, Promise<string | null>>,
714): Promise<ReadonlyMap<string, string>> {
715 const notices = new Map<string, string>();
716 const candidates = await Promise.all(
717 [...collection.byIdentity.values()].map(async (collected) => {
718 const license = normalizeLicense(collected.packageJson);
719 if (!license) return null;
720 const key = repositoryNoticeKey(collected.packageJson, license);
721 if (!key) return null;
722 const noticeText = await packageNoticeText(collected.packageRoot, packageNotices);
723 return noticeText ? { key, noticeText } : null;
724 }),
725 );
726 for (const candidate of candidates) {
727 if (candidate && !notices.has(candidate.key)) notices.set(candidate.key, candidate.noticeText);
728 }
729 return notices;
730}
731
732function packageNoticeText(
733 packageRoot: string,
734 cache: Map<string, Promise<string | null>>,
735): Promise<string | null> {
736 const existing = cache.get(packageRoot);
737 if (existing) return existing;
738 const notice = readPackageNoticeText(packageRoot);
739 cache.set(packageRoot, notice);
740 return notice;
741}
742
743function findPackageOverride(
744 overrides: ReadonlyArray<PackageNoticeOverrideConfigEntry>,
745 name: string,
746 version: string,
747 packageJson: PackageJson,
748): PackageNoticeOverrideConfigEntry | undefined {
749 const repositoryUrl = normalizeRepositoryUrl(packageJson.repository)?.toLowerCase();
750 const matchesRepository = (override: PackageNoticeOverrideConfigEntry) =>
751 repositoryUrl !== undefined &&
752 override.repositoryUrl !== undefined &&
753 normalizeRepositoryUrl(override.repositoryUrl)?.toLowerCase() === repositoryUrl;
754 return (
755 overrides.find((override) => override.name === name && override.version === version) ??
756 overrides.find((override) => override.name === name && override.version === undefined) ??
757 overrides.find((override) => matchesRepository(override) && override.version === version) ??
758 overrides.find((override) => matchesRepository(override) && override.version === undefined)
759 );
760}
761
762async function packageEntry(
763 collected: CollectedPackage,
764 config: ThirdPartyLicensesConfig,
765 configDirectory: string,
766 packageNotices: Map<string, Promise<string | null>>,
767 repositoryNotices: ReadonlyMap<string, string>,
768 allowMissingGeneratedNotices: boolean,
769): Promise<ThirdPartyLicenseEntry | null> {
770 const name =
771 typeof collected.packageJson.name === "string"
772 ? collected.packageJson.name
773 : NodePath.basename(collected.packageRoot);
774 const version =
775 typeof collected.packageJson.version === "string" ? collected.packageJson.version : "unknown";
776 const override = findPackageOverride(
777 config.packageOverrides,
778 name,
779 version,
780 collected.packageJson,
781 );
782 const license = override?.license ?? normalizeLicense(collected.packageJson);
783 if (!license || /^(?:unlicensed|proprietary)$/i.test(license)) {
784 throw new Error(
785 `${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.`,
786 );
787 }
788
789 const repositoryKey = repositoryNoticeKey(collected.packageJson, license);
790 const noticeText = override?.generatedNotices
791 ? await generatedNoticeText(
792 override.generatedNotices,
793 configDirectory,
794 allowMissingGeneratedNotices,
795 )
796 : override?.noticeFile
797 ? (
798 await NodeFSP.readFile(NodePath.resolve(configDirectory, override.noticeFile), "utf8")
799 ).trim()
800 : ((await packageNoticeText(collected.packageRoot, packageNotices)) ??
801 (repositoryKey ? repositoryNotices.get(repositoryKey) : undefined));
802 if (!noticeText) {
803 if (override?.generatedNotices && allowMissingGeneratedNotices) return null;
804 throw new Error(
805 `${name}@${version} does not include a license or notice file. Add a package override with "noticeFile" or "generatedNotice" in the third-party license config.`,
806 );
807 }
808
809 return {
810 bundles: [...collected.bundles].sort((left, right) => left.localeCompare(right)),
811 kind: "package",
812 license,
813 name,
814 noticeText,
815 sourceUrl: override?.sourceUrl ?? packageSourceUrl(collected.packageJson),
816 version,
817 };
818}
819
820async function customEntries(
821 config: ThirdPartyLicensesConfig,
822 configDirectory: string,
823 includedBundles: ReadonlySet<string>,
824 allowMissingGeneratedNotices: boolean,
825): Promise<ReadonlyArray<ThirdPartyLicenseEntry>> {
826 const deviceToolVersions: unknown = JSON.parse(
827 await NodeFSP.readFile(
828 new URL("../../apps/server/src/device/deviceToolVersions.json", import.meta.url),
829 "utf8",
830 ),
831 );
832 if (!isRecord(deviceToolVersions)) throw new Error("Device tool versions must be an object.");
833 const entries = await Promise.all(
834 config.customNotices
835 .filter(
836 (notice) =>
837 (notice.includeInBundles ?? notice.bundles) === undefined ||
838 (notice.includeInBundles ?? notice.bundles)?.some((bundle) =>
839 includedBundles.has(bundle),
840 ),
841 )
842 .map(async (notice) => {
843 const noticeText = notice.generatedNotices
844 ? await generatedNoticeText(
845 notice.generatedNotices,
846 configDirectory,
847 allowMissingGeneratedNotices,
848 )
849 : (
850 await Promise.all(
851 notice.noticeFiles!.map(async (noticeFile) => {
852 const contents = (
853 await NodeFSP.readFile(NodePath.resolve(configDirectory, noticeFile), "utf8")
854 ).trim();
855 if (contents.length === 0) {
856 throw new Error(`Custom third-party notice "${notice.name}" is empty.`);
857 }
858 return contents;
859 }),
860 )
861 ).join("\n\n---\n\n");
862 if (noticeText === null) {
863 if (allowMissingGeneratedNotices) return null;
864 throw new Error(`Could not generate custom third-party notice "${notice.name}".`);
865 }
866 if (noticeText.length === 0) {
867 throw new Error(`Custom third-party notice "${notice.name}" is empty.`);
868 }
869 return {
870 bundles: [...(notice.bundles ?? ["assets"])].sort((left, right) =>
871 left.localeCompare(right),
872 ),
873 kind: "custom" as const,
874 license: notice.license,
875 name: notice.name,
876 noticeText,
877 sourceUrl: notice.sourceUrl ?? null,
878 version:
879 readOptionalString(deviceToolVersions, notice.name, "Device tool versions") ??
880 notice.version ??
881 null,
882 };
883 }),
884 );
885 return entries.flatMap((entry) => (entry ? [entry] : []));
886}
887
888function entrySort(left: ThirdPartyLicenseEntry, right: ThirdPartyLicenseEntry): number {
889 return (
890 left.name.localeCompare(right.name) ||
891 (left.version ?? "").localeCompare(right.version ?? "") ||
892 left.kind.localeCompare(right.kind)
893 );
894}
895
896function assertUniqueEntries(entries: ReadonlyArray<ThirdPartyLicenseEntry>): void {
897 const identities = new Set<string>();
898 for (const entry of entries) {
899 const identity = JSON.stringify([entry.kind, entry.name, entry.version]);
900 if (identities.has(identity)) {
901 throw new Error(
902 `Third-party license generation found a duplicate ${entry.kind} notice for ${entry.name}${entry.version ? `@${entry.version}` : ""}.`,
903 );
904 }
905 identities.add(identity);
906 }
907}
908
909export async function generateThirdPartyLicenseManifest(input: {
910 readonly configFile?: string | URL;
911 readonly packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>;
912 readonly bundledModuleIds?: ReadonlyArray<string>;
913 readonly bundleName?: string;
914 readonly allowMissingGeneratedNotices?: boolean;
915}): Promise<ThirdPartyLicenseManifest> {
916 const [{ config, directory }, collection] = await Promise.all([
917 readConfig(input.configFile),
918 collectProductionDependencyPackages(input.packageManifests),
919 ]);
920 if (!(input.allowMissingGeneratedNotices ?? false)) {
921 await syncConfiguredGeneratedNotices(config, directory);
922 }
923 if (input.bundledModuleIds && input.bundleName) {
924 await addBundledModulePackages(collection, input.bundledModuleIds, input.bundleName);
925 }
926
927 const packageNotices = new Map<string, Promise<string | null>>();
928 const repositoryNotices = await collectRepositoryNotices(collection, packageNotices);
929
930 const packageEntryResults = await Promise.allSettled(
931 [...collection.byIdentity.values()].map((collected) =>
932 packageEntry(
933 collected,
934 config,
935 directory,
936 packageNotices,
937 repositoryNotices,
938 input.allowMissingGeneratedNotices ?? false,
939 ),
940 ),
941 );
942 const failures = packageEntryResults.flatMap((result) =>
943 result.status === "rejected"
944 ? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
945 : [],
946 );
947 if (failures.length > 0) {
948 throw new Error(
949 `Third-party license generation found ${String(failures.length)} invalid package notice${failures.length === 1 ? "" : "s"}:\n${failures.map((failure) => `- ${failure}`).join("\n")}`,
950 );
951 }
952 const packageEntries = packageEntryResults.flatMap((result) =>
953 result.status === "fulfilled" && result.value ? [result.value] : [],
954 );
955 const includedBundles = new Set(input.packageManifests.map((manifest) => manifest.bundle));
956 if (input.bundleName) includedBundles.add(input.bundleName);
957 const manualEntries = await customEntries(
958 config,
959 directory,
960 includedBundles,
961 input.allowMissingGeneratedNotices ?? false,
962 );
963 const entries = [...packageEntries, ...manualEntries].sort(entrySort);
964 assertUniqueEntries(entries);
965 return {
966 schemaVersion: 1,
967 entries,
968 };
969}
970
971function moduleIdsFromBundle(bundle: unknown): ReadonlyArray<string> {
972 const ids = new Set<string>();
973 if (!isRecord(bundle)) return [];
974 for (const output of Object.values(bundle)) {
975 if (!isRecord(output) || output.type !== "chunk" || !isRecord(output.modules)) continue;
976 for (const id of Object.keys(output.modules)) ids.add(id);
977 }
978 return [...ids];
979}
980
981function serializeManifest(manifest: ThirdPartyLicenseManifest): string {
982 return `${JSON.stringify(manifest)}\n`;
983}
984
985export function thirdPartyLicensesPlugin(options: ThirdPartyLicensesPluginOptions): Plugin {
986 return {
987 name: "t3code:third-party-licenses",
988 configureServer(server) {
989 let manifestPromise: Promise<ThirdPartyLicenseManifest> | null = null;
990 server.middlewares.use((request, response, next) => {
991 if (request.url?.split("?", 1)[0] !== `/${THIRD_PARTY_LICENSES_FILE_NAME}`) {
992 next();
993 return;
994 }
995 manifestPromise ??= generateThirdPartyLicenseManifest({
996 packageManifests: options.packageManifests,
997 bundleName: options.bundleName,
998 allowMissingGeneratedNotices: true,
999 ...(options.configFile !== undefined ? { configFile: options.configFile } : {}),
1000 }).catch((error: unknown) => {
1001 manifestPromise = null;
1002 throw error;
1003 });
1004 void manifestPromise.then(
1005 (manifest) => {
1006 response.statusCode = 200;
1007 response.setHeader("Content-Type", "application/json; charset=utf-8");
1008 response.setHeader("Cache-Control", "no-store");
1009 response.end(serializeManifest(manifest));
1010 },
1011 (error: unknown) => {
1012 next(error instanceof Error ? error : new Error(String(error)));
1013 },
1014 );
1015 });
1016 },
1017 async generateBundle(_outputOptions, bundle) {
1018 const manifest = await generateThirdPartyLicenseManifest({
1019 packageManifests: options.packageManifests,
1020 bundledModuleIds: moduleIdsFromBundle(bundle),
1021 bundleName: options.bundleName,
1022 ...(options.configFile !== undefined ? { configFile: options.configFile } : {}),
1023 });
1024 this.emitFile({
1025 type: "asset",
1026 fileName: THIRD_PARTY_LICENSES_FILE_NAME,
1027 source: serializeManifest(manifest),
1028 });
1029 },
1030 };
1031}