crossbind 2.0.0-beta.73 → 2.0.0-beta.74

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crossbind",
3
- "version": "2.0.0-beta.73",
3
+ "version": "2.0.0-beta.74",
4
4
  "license": "MIT",
5
5
  "homepage": "https://crossbind.dev",
6
6
  "repository": {
@@ -27,6 +27,10 @@
27
27
  "types": "./src/app/index.d.ts",
28
28
  "default": "./src/app/index.js"
29
29
  },
30
+ "./licenses": {
31
+ "types": "./src/licenses/index.d.ts",
32
+ "default": "./src/licenses/index.js"
33
+ },
30
34
  "./*": "./*"
31
35
  },
32
36
  "devDependencies": {
@@ -34,7 +38,7 @@
34
38
  "vitest": "^5.0.1"
35
39
  },
36
40
  "dependencies": {
37
- "@crossbind/extension-conan": "2.0.0-beta.72",
41
+ "@crossbind/extension-conan": "2.0.0-beta.74",
38
42
  "@rollup/plugin-commonjs": "^29.0.3",
39
43
  "@rollup/plugin-json": "^6.1.0",
40
44
  "@rollup/plugin-node-resolve": "^16.0.3",
@@ -51,7 +55,7 @@
51
55
  "upath": "^3.0.8"
52
56
  },
53
57
  "optionalDependencies": {
54
- "@crossbind/bridge-tools": "2.0.0-beta.73"
58
+ "@crossbind/bridge-tools": "2.0.0-beta.74"
55
59
  },
56
60
  "keywords": [
57
61
  "webassembly",
@@ -48,7 +48,10 @@ export default function createLib(target, fileType, options = {}) {
48
48
  // The lib dir is reused while the bridge list grows, so an early smaller nativeGlob build must not satisfy later sets: fingerprint the glob and miss on mismatch.
49
49
  const fingerprintFile = `${libdir}/crossbind-nativeglob.fingerprint`;
50
50
  // options.inputs lists what the sources compile against (a binding runtime's headers), so a change there rebuilds too.
51
- const fingerprint = nativeGlob ? getFilesFingerprint([...nativeGlob, ...(options.inputs ?? [])]) : null;
51
+ // A header edit can leave a bridge's text as it was, so the key beside each bridge, the hash of what it was made from, counts too.
52
+ const fingerprint = nativeGlob
53
+ ? getFilesFingerprint([...nativeGlob, ...nativeGlob.map((file) => `${file}.key`), ...(options.inputs ?? [])])
54
+ : null;
52
55
  const fingerprintChanged = fingerprint !== null
53
56
  && (!fs.existsSync(fingerprintFile) || fs.readFileSync(fingerprintFile, { encoding: 'utf8' }) !== fingerprint);
54
57
 
@@ -113,6 +113,32 @@ function wasiToolchainRows() {
113
113
  ];
114
114
  }
115
115
 
116
+ // A wasm module statically links Emscripten's runtime and system libraries (its musl-based libc among them) and LLVM's
117
+ // C++ runtime from the same toolchain image.
118
+ function wasmToolchainRows() {
119
+ const shared = {
120
+ npmName: null, version: null, nativeVersion: null, sha256: null, licenseSelected: null, licenseText: null, isCopyleft: false,
121
+ };
122
+ return [
123
+ {
124
+ ...shared,
125
+ name: 'emscripten',
126
+ license: 'MIT OR NCSA',
127
+ licenseDeclared: 'MIT OR NCSA',
128
+ sourceUrl: 'https://github.com/emscripten-core/emscripten',
129
+ licenseNotes: "Emscripten's runtime and system libraries, its musl-based libc among them, statically linked into the module; license text: https://github.com/emscripten-core/emscripten/blob/main/LICENSE",
130
+ },
131
+ {
132
+ ...shared,
133
+ name: 'llvm-runtimes',
134
+ license: 'Apache-2.0 WITH LLVM-exception',
135
+ licenseDeclared: 'Apache-2.0 WITH LLVM-exception',
136
+ sourceUrl: 'https://github.com/llvm/llvm-project',
137
+ licenseNotes: "libc++, libc++abi and compiler-rt of Emscripten's LLVM, statically linked into the module; license text: https://github.com/llvm/llvm-project/blob/main/LICENSE.TXT",
138
+ },
139
+ ];
140
+ }
141
+
116
142
  // The notices a build copied out of its toolchain image (a Windows addon's or app's does).
117
143
  function toolchainNoticeTexts(platform) {
118
144
  const build = state.config?.paths?.build;
@@ -160,25 +186,31 @@ function addonToolchainRows(platform) {
160
186
  const versionOf = (dir) => JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
161
187
  const textOf = (file) => fs.readFileSync(file, 'utf8').trim();
162
188
 
189
+ const RUNTIME_ROW = {
190
+ nativeVersion: null, sha256: null, licenseSelected: null, isCopyleft: false, isRuntime: true,
191
+ };
192
+
193
+ // crossbind's own code: what an addon and an app host compile in, or the JavaScript a wasm module's glue bundles.
194
+ function crossbindRow(notes) {
195
+ return {
196
+ ...RUNTIME_ROW,
197
+ name: 'crossbind',
198
+ npmName: 'crossbind',
199
+ version: versionOf(CROSSBIND_ROOT),
200
+ license: 'MIT',
201
+ licenseDeclared: 'MIT',
202
+ sourceUrl: 'https://github.com/crossbind/crossbind',
203
+ licenseNotes: notes,
204
+ licenseText: textOf(path.join(CROSSBIND_ROOT, 'LICENSE')),
205
+ };
206
+ }
207
+
163
208
  // crossbind's runtime, which an addon and an app host compile in: embind-jsi, which adapts Emscripten's embind.
164
209
  function runtimeRows(jsiRoot, notes) {
165
- const shared = {
166
- nativeVersion: null, sha256: null, licenseSelected: null, isCopyleft: false,
167
- };
168
210
  return [
211
+ crossbindRow(notes),
169
212
  {
170
- ...shared,
171
- name: 'crossbind',
172
- npmName: 'crossbind',
173
- version: versionOf(CROSSBIND_ROOT),
174
- license: 'MIT',
175
- licenseDeclared: 'MIT',
176
- sourceUrl: 'https://github.com/crossbind/crossbind',
177
- licenseNotes: notes,
178
- licenseText: textOf(path.join(CROSSBIND_ROOT, 'LICENSE')),
179
- },
180
- {
181
- ...shared,
213
+ ...RUNTIME_ROW,
182
214
  name: 'emscripten-embind',
183
215
  npmName: '@crossbind/core-embind-jsi',
184
216
  version: versionOf(jsiRoot),
@@ -420,10 +452,13 @@ async function buildRow(node) {
420
452
  sourceUrl = null;
421
453
  }
422
454
  }
423
- if (!sourceUrl) sourceUrl = manifest?.homepage || null;
424
-
425
455
  const family = familyManifestOf(node);
426
- const upstream = family?.manifest?.crossbind?.upstream?.license || null;
456
+ // A project's own code is at its repository; its homepage is where its docs are.
457
+ if (!sourceUrl) sourceUrl = repositoryUrl(family?.manifest) || repositoryUrl(manifest) || manifest?.homepage || null;
458
+
459
+ // The upstream identity scanners match a component on: the project's own name, and its CPE where NVD has one.
460
+ const identity = family?.manifest?.crossbind?.upstream ?? null;
461
+ const upstream = identity?.license || null;
427
462
  let license;
428
463
  let licenseText;
429
464
  if (upstream) {
@@ -437,6 +472,12 @@ async function buildRow(node) {
437
472
  if (isComplete) licenseText = fromSource;
438
473
  else if (shipped) licenseText = `=== ${path.basename(shipped)} ===\n\n${fs.readFileSync(shipped, 'utf8').trim()}`;
439
474
  else licenseText = readLicenseTexts(upstream.files, () => null);
475
+ } else if (family && family.dir !== node.paths.project) {
476
+ // The package the alias names holds the code: a published binary's own license field is the expression of
477
+ // everything it links, which this row would otherwise repeat as its own.
478
+ license = family.manifest.license || manifest?.license || null;
479
+ const own = packageLicenseFile(family.dir) ?? packageLicenseFile(node.paths.project);
480
+ licenseText = own ? fs.readFileSync(own, 'utf8') : null;
440
481
  } else {
441
482
  license = manifest?.license || null;
442
483
  const legacy = packageLicenseFile(node.paths.project);
@@ -444,10 +485,11 @@ async function buildRow(node) {
444
485
  }
445
486
 
446
487
  return {
447
- name: node.general.name,
488
+ name: identity?.name ?? node.general.name,
448
489
  npmName: manifest?.name || null,
449
490
  version: manifest?.version || null,
450
491
  nativeVersion,
492
+ cpe: identity?.cpe ? `cpe:2.3:a:${identity.cpe}:${nativeVersion ?? '*'}:*:*:*:*:*:*:*` : null,
451
493
  license,
452
494
  licenseDeclared: upstream?.declared || null,
453
495
  licenseSelected: upstream?.selected || null,
@@ -456,9 +498,21 @@ async function buildRow(node) {
456
498
  sourceUrl,
457
499
  licenseText,
458
500
  isCopyleft: isCopyleft(license),
501
+ isPort: Boolean(upstream),
502
+ shipsData: shipsData(node),
459
503
  };
460
504
  }
461
505
 
506
+ export function repositoryUrl(manifest) {
507
+ const repository = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url;
508
+ return repository ? repository.replace(/^git\+/, '').replace(/\.git$/, '') : null;
509
+ }
510
+
511
+ // The data a dependency declares (GDAL's and PROJ's) travels with the package that ships the module or its loader.
512
+ function shipsData(node) {
513
+ return (node.targetSpecs ?? []).some((entry) => entry?.specs?.data && Object.keys(entry.specs.data).length > 0);
514
+ }
515
+
462
516
  // The facts of a package source's package come from its recipe, and its texts from the licenses/ its package ships.
463
517
  function sourcePackageRow(node) {
464
518
  const { name: source, package: pkg } = node.general.packageSource;
@@ -497,9 +551,22 @@ function assertSourcesStaged() {
497
551
  });
498
552
  }
499
553
 
554
+ // The libraries whose recipes vendor copies for some platform, which only a listing for that platform includes.
555
+ export async function vendoringLibraries() {
556
+ const names = [];
557
+ for (const node of [state.config, ...state.config.allDependencies]) {
558
+ if (!node?.paths?.project || node.general?.packageSource) continue;
559
+ const recipe = await loadJs(node.paths.project, 'crossbind.build');
560
+ const isVendoring = Object.values(recipe?.bundled ?? {}).some((entries) => entries?.length > 0);
561
+ if (isVendoring && !names.includes(node.general.name)) names.push(node.general.name);
562
+ }
563
+ return names;
564
+ }
565
+
500
566
  // platform (optional, e.g. 'wasi') additionally includes what that platform's
501
567
  // artifact statically links beyond the package graph: recipe-declared vendored
502
- // copies and the toolchain runtime. runtimeEnv 'node' adds the crossbind runtime an
568
+ // copies and the toolchain runtime, and for wasm crossbind's runtime, which the
569
+ // module's glue bundles. runtimeEnv 'node' adds the crossbind runtime an
503
570
  // addon of that platform compiles in, or without a platform the one its loader bundles;
504
571
  // runtimeEnv 'app' adds what an app's host carries.
505
572
  export default async function collectLicenseRows(platform = null, { runtimeEnv = null } = {}) {
@@ -513,12 +580,16 @@ export default async function collectLicenseRows(platform = null, { runtimeEnv =
513
580
  const nodes = [state.config, ...state.config.allDependencies]
514
581
  .filter((node, index) => node?.paths?.project
515
582
  && (node.general?.alias?.package || node.general?.packageSource || (index > 0 && node.package?.name && !node.package.private)));
583
+ // A project can depend on a library's packages for several platforms; the report names the one its platform links.
584
+ const target = platform ? state.targets?.find((t) => t.platform === platform) : null;
585
+ const serves = (node) => !target || node.functions?.isEnabled?.(target) !== false;
516
586
  const rows = [];
517
587
  const seen = new Set();
518
- for (const node of nodes) {
519
- const key = keyOf(node);
588
+ for (const listed of nodes) {
589
+ const key = keyOf(listed);
520
590
  if (seen.has(key)) continue;
521
591
  seen.add(key);
592
+ const node = nodes.find((other) => keyOf(other) === key && serves(other)) ?? listed;
522
593
  rows.push(node.general.packageSource ? sourcePackageRow(node) : await buildRow(node));
523
594
  // A package source's package folder is written from what its recipe produced: nothing there is run.
524
595
  if (platform && !node.general.packageSource) {
@@ -527,6 +598,7 @@ export default async function collectLicenseRows(platform = null, { runtimeEnv =
527
598
  }
528
599
  }
529
600
  if (platform === 'wasi') rows.push(...wasiToolchainRows());
601
+ if (platform === 'wasm') rows.push(...wasmToolchainRows(), crossbindRow("crossbind's JavaScript runtime, which the module's glue bundles"));
530
602
  if (['linux', 'linuxmusl', 'win32'].includes(platform)) rows.push(...addonToolchainRows(platform));
531
603
  if (runtimeEnv === 'node') rows.push(...nodeRuntimeRows({ isAddon: platform !== null }));
532
604
  if (runtimeEnv === 'app') {
@@ -0,0 +1,89 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import state from '../state/index.js';
4
+ import collectLicenseRows, { repositoryUrl } from './licenses.js';
5
+
6
+ const FILE = 'crossbind-licenses.json';
7
+ // crossbind/licenses, from whichever copy of crossbind the page has.
8
+ const HELPER = /[\\/]crossbind[\\/]src[\\/]licenses[\\/]index\.js$/;
9
+ const TEXT_FILE = /^(licen[cs]e|copying|notice|ofl)([._-].*)?$/i;
10
+
11
+ // What a bundle holds: its chunks' modules, and the files its assets came from, as the font a stylesheet's url() takes.
12
+ function bundledFiles(bundle, root) {
13
+ return Object.values(bundle)
14
+ .flatMap((output) => (output.type === 'chunk'
15
+ ? output.moduleIds
16
+ : (output.originalFileNames ?? []).map((name) => path.resolve(root, name))))
17
+ .map((id) => id.split('?')[0]);
18
+ }
19
+
20
+ function manifestIn(dir, manifests) {
21
+ const file = path.join(dir, 'package.json');
22
+ if (!manifests.has(file)) {
23
+ try {
24
+ manifests.set(file, fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null);
25
+ } catch (error) {
26
+ throw new Error(`crossbind: ${file} holds no JSON: ${error.message}`, { cause: error });
27
+ }
28
+ }
29
+ return manifests.get(file);
30
+ }
31
+
32
+ // The package a file is in: the nearest folder up from it whose package.json names one, as a package's subfolder can
33
+ // hold one that names none.
34
+ function packageOf(file, manifests) {
35
+ if (!path.isAbsolute(file)) return null;
36
+ for (let dir = path.dirname(path.resolve(file)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
37
+ const manifest = manifestIn(dir, manifests);
38
+ if (manifest?.name) return { dir, manifest };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function npmEntry({ dir, manifest }) {
44
+ const texts = fs.readdirSync(dir, { withFileTypes: true })
45
+ .filter((entry) => entry.isFile() && TEXT_FILE.test(entry.name))
46
+ .map((entry) => entry.name)
47
+ .sort()
48
+ .map((file) => `=== ${file} ===\n\n${fs.readFileSync(path.join(dir, file), 'utf8').trim()}`);
49
+ return {
50
+ name: manifest.name,
51
+ version: manifest.version ?? null,
52
+ license: typeof manifest.license === 'string' ? manifest.license : manifest.license?.type ?? null,
53
+ source: repositoryUrl(manifest) ?? manifest.homepage ?? null,
54
+ text: texts.length > 0 ? texts.join('\n\n') : null,
55
+ };
56
+ }
57
+
58
+ // The npm packages the page carries, but for the project's own code: its package and private ones.
59
+ function npmEntriesOf(files) {
60
+ const manifests = new Map();
61
+ const project = path.resolve(state.config.paths.project);
62
+ const packages = new Map(files
63
+ .map((file) => packageOf(file, manifests))
64
+ .filter((found) => found && found.dir !== project && !found.manifest.private)
65
+ .map((found) => [found.dir, found]));
66
+ return [...packages.values()].map(npmEntry);
67
+ }
68
+
69
+ const nativeEntry = (row) => ({
70
+ name: row.name,
71
+ version: row.nativeVersion ?? row.version ?? null,
72
+ license: row.license ?? null,
73
+ source: row.sourceUrl ?? null,
74
+ text: row.licenseText ?? null,
75
+ });
76
+
77
+ // The list a page reads with crossbind/licenses, which its build writes beside it if it does: what the module links and
78
+ // an app carries, as `crossbind licenses` lists them, and the npm packages the page's bundle holds, each once. bundle is
79
+ // what Rollup or Vite hands generateBundle, root what its assets' original file names are relative to.
80
+ export default async function pageLicensesAsset(bundle, { root, platform, runtimeEnv = null }) {
81
+ const files = bundledFiles(bundle, root);
82
+ if (!files.some((file) => HELPER.test(file))) return null;
83
+ const native = (await collectLicenseRows(platform, { runtimeEnv })).map(nativeEntry);
84
+ const listed = new Set();
85
+ const entries = [...native, ...npmEntriesOf(files)]
86
+ .filter(({ name, version }) => !listed.has(`${name}@${version}`) && listed.add(`${name}@${version}`))
87
+ .sort((a, b) => a.name.localeCompare(b.name));
88
+ return { fileName: FILE, source: JSON.stringify(entries) };
89
+ }
package/src/bin.js CHANGED
@@ -48,9 +48,9 @@ import { cargoHome } from './utils/runCargo.js';
48
48
  import { containerRootOf, sourceRoot } from './utils/runPackageSource.js';
49
49
  import { importSchemesOf, sourceNamesOf } from './utils/packageSources.js';
50
50
  import { cleanDepsCache } from './utils/dependencyRebuild.js';
51
- import collectLicenseRows from './actions/licenses.js';
51
+ import collectLicenseRows, { vendoringLibraries } from './actions/licenses.js';
52
52
  import { formatNoticesMarkdown, formatCycloneDxSbom, validateSpdx } from './utils/licenseReport.js';
53
- import { SBOM_FILE, nodePackageMeta, writeNodePackageLicense } from './utils/packageLicense.js';
53
+ import { SBOM_FILE, nodePackageMeta, shippedRows, writeNodePackageLicense } from './utils/packageLicense.js';
54
54
  import findFiles from './utils/findFiles.js';
55
55
 
56
56
  const platforms = [...new Set(state.targets.map(target => target.platform).filter(t => t))];
@@ -240,11 +240,17 @@ program.command('licenses')
240
240
  });
241
241
  const copyleftRows = rows.filter((row) => row.isCopyleft);
242
242
  if (copyleftRows.length > 0) {
243
- console.log(`\n! ${copyleftRows.length} copyleft-licensed ${copyleftRows.length === 1 ? 'dependency' : 'dependencies'} (${copyleftRows.map((row) => row.name).join(', ')}) — see docs/playbooks/licensing-lgpl.md`);
243
+ console.log(`\n! ${copyleftRows.length} copyleft-licensed ${copyleftRows.length === 1 ? 'dependency' : 'dependencies'} (${copyleftRows.map((row) => row.name).join(', ')}) — see https://github.com/crossbind/crossbind/blob/main/docs/playbooks/licensing-lgpl.md`);
244
+ }
245
+ if ((options.notices || options.sbom) && !options.platform) {
246
+ const vendoring = await vendoringLibraries();
247
+ if (vendoring.length > 0) {
248
+ console.warn(`crossbind: ${vendoring.join(', ')} vendor${vendoring.length === 1 ? 's' : ''} copies of other libraries per platform; pass --platform <platform> so the notices and SBOM list them.`);
249
+ }
244
250
  }
245
251
  if (options.notices) {
246
252
  const file = typeof options.notices === 'string' ? options.notices : `${state.config.paths.project}/THIRD-PARTY-NOTICES.md`;
247
- fs.writeFileSync(file, formatNoticesMarkdown(rows));
253
+ fs.writeFileSync(file, formatNoticesMarkdown(rows, nodePackageMeta(state.config)));
248
254
  logger.info(`crossbind: wrote ${file}`);
249
255
  }
250
256
  if (options.sbom) {
@@ -265,7 +271,8 @@ program.command('licenses')
265
271
  logger.info(`crossbind: wrote ${file}`);
266
272
  }
267
273
  if (options.package) {
268
- const expression = writeNodePackageLicense(state.config.paths.project, { ...nodePackageMeta(state.config), rows });
274
+ const meta = nodePackageMeta(state.config);
275
+ const expression = writeNodePackageLicense(state.config.paths.project, { ...meta, rows: shippedRows(rows, meta) });
269
276
  logger.info(`crossbind: wrote LICENSE and ${SBOM_FILE} (${expression})`);
270
277
  }
271
278
  if (options.check) {
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  export { checkInstaller, shipApp } from './actions/appInstaller.js';
10
10
  export { findAppPageApi, resolveAppPageApi } from './utils/resolveAppHost.js';
11
11
  export { default as isAppBuild } from './utils/appBuild.js';
12
+ export { default as pageLicensesAsset } from './actions/pageLicenses.js';
12
13
  export { default as openInSimulator, buildForSimulator } from './actions/iosSimulator.js';
13
14
  export {
14
15
  default as openOnIosDevice, buildForIosDevice, connectedIosDevice, iosDeviceAddress, iosDevServer,
@@ -0,0 +1,16 @@
1
+ /** A component a page or the app it runs in carries: a library the native module links, a part of an app's host or crossbind's runtime, or an npm package in the page's bundle. */
2
+ export interface License {
3
+ /** The name its project gives it. */
4
+ name: string;
5
+ /** The version of the code the page or app carries. */
6
+ version: string | null;
7
+ /** An SPDX expression. */
8
+ license: string | null;
9
+ /** Where its code is. */
10
+ source: string | null;
11
+ /** Its license texts as it carries them, each under its file's name. */
12
+ text: string | null;
13
+ }
14
+
15
+ /** The components the page and its app carry, by name, from the crossbind-licenses.json a build with crossbind's Vite or Rollup plugin writes beside the page. Rejects on a dev server, which has none. */
16
+ export function licenses(): Promise<License[]>;
@@ -0,0 +1,15 @@
1
+ // The licenses of what a page and the app it runs in carry, from the crossbind-licenses.json a build of the page with
2
+ // crossbind's Vite or Rollup plugin writes beside it.
3
+ const FILE = 'crossbind-licenses.json';
4
+
5
+ export async function licenses() {
6
+ // Vite gives the base it serves the page from; elsewhere the file is beside the page.
7
+ const url = new URL(`${import.meta.env?.BASE_URL ?? './'}${FILE}`, document.baseURI);
8
+ const response = await fetch(url);
9
+ // A dev server answers a file it lacks with the page.
10
+ const list = response.ok ? await response.json().catch(() => null) : null;
11
+ if (!Array.isArray(list)) {
12
+ throw new Error(`crossbind: ${url} holds no license list. A build of the page with crossbind's Vite or Rollup plugin writes it; the dev server has none.`);
13
+ }
14
+ return list;
15
+ }
@@ -146,6 +146,8 @@ export function getFilledConfig(config, options = { isDepend: false }) {
146
146
  app: config.app || {},
147
147
  // What an app's host builds in beside its own extensions.
148
148
  extensions: config.extensions || [],
149
+ // The license files crossbind licenses writes: sourceTag is the git tag a published package's source is at.
150
+ licenses: config.licenses || {},
149
151
  package: null,
150
152
  functions: config.functions || {},
151
153
  };
@@ -153,6 +155,9 @@ export function getFilledConfig(config, options = { isDepend: false }) {
153
155
  if (!['sync', 'promise'].includes(newConfig.dts)) {
154
156
  throw new Error(`crossbind: "dts" must be 'sync' or 'promise' (got '${newConfig.dts}').`);
155
157
  }
158
+ if (newConfig.licenses.sourceTag !== undefined && typeof newConfig.licenses.sourceTag !== 'string') {
159
+ throw new Error(`crossbind: "licenses.sourceTag" must be a string such as 'v{version}' (got '${newConfig.licenses.sourceTag}').`);
160
+ }
156
161
  if (typeof newConfig.types !== 'boolean') {
157
162
  throw new Error(`crossbind: "types" must be true or false (got '${newConfig.types}').`);
158
163
  }
@@ -200,18 +200,15 @@ export function parseCppSurface(source, log = console.log) {
200
200
 
201
201
  export function emitCppDts(model, exportNames, mode = 'sync') {
202
202
  const wrap = (t) => (mode === 'promise' ? `Promise<${t}>` : t);
203
- const out = ['// Generated by crossbind - do not edit. Values are usable after init().', ''];
204
- const usesVector = model.classes.some((cls) => (cls.ctor?.args ?? []).concat(cls.methods.flatMap((m) => [...m.args, { type: m.ret }]))
205
- .some((a) => a.type?.includes('CppVector<')));
206
- if (usesVector) {
207
- out.push('/** An embind std::vector proxy. Convert with Module.toArray / Module.toVector. */');
208
- out.push('export interface CppVector<T> {');
209
- out.push(` size(): ${wrap('number')};`);
210
- out.push(` get(index: number): ${wrap('T')};`);
211
- out.push(` push_back(value: T): ${wrap('void')};`);
212
- out.push(` delete(): ${wrap('void')};`);
213
- out.push('}');
214
- }
203
+ const out = ['// Generated by crossbind - do not edit. Values are usable after initNative() resolves.', ''];
204
+ // The module's toVector and toArray take vectors on every runtime, so the proxy's type is always declared.
205
+ out.push('/** An embind std::vector proxy. Convert with Module.toArray / Module.toVector. */');
206
+ out.push('export interface CppVector<T> {');
207
+ out.push(` size(): ${wrap('number')};`);
208
+ out.push(` get(index: number): ${wrap('T')};`);
209
+ out.push(` push_back(value: T): ${wrap('void')};`);
210
+ out.push(` delete(): ${wrap('void')};`);
211
+ out.push('}');
215
212
  const emitted = new Set();
216
213
  for (const cls of model.classes) {
217
214
  if (!exportNames.includes(cls.name)) continue;
@@ -233,7 +230,16 @@ export function emitCppDts(model, exportNames, mode = 'sync') {
233
230
  if (!emitted.has(name)) out.push(`export declare const ${name}: any;`);
234
231
  }
235
232
  out.push('export declare let AllSymbols: Record<string, unknown>;');
236
- out.push('export declare function initNative(config?: Record<string, unknown>): Promise<unknown>;');
233
+ // A runtime's own members (FS in a wasm build, the file helpers in a browser) differ by runtime; the index signature
234
+ // keeps them reachable while the members every runtime has stay typed.
235
+ out.push('/** What initNative resolves to: the bound classes, toVector and toArray, and the members of the runtime it runs on. */');
236
+ out.push('export interface NativeModule {');
237
+ out.push(' [name: string]: unknown;');
238
+ for (const name of emitted) out.push(` ${name}: typeof ${name};`);
239
+ out.push(` toVector<T>(classOrName: unknown, array?: T[]): ${wrap('CppVector<T>')};`);
240
+ out.push(` toArray<T>(vector: CppVector<T> | T[]): ${wrap('T[]')};`);
241
+ out.push('}');
242
+ out.push('export declare function initNative(config?: Record<string, unknown>): Promise<NativeModule>;');
237
243
  out.push('');
238
244
  return out.join('\n');
239
245
  }
@@ -1,4 +1,5 @@
1
1
  import spdxParse from 'spdx-expression-parse';
2
+ import { lgplRows, relinkSection } from './relinking.js';
2
3
 
3
4
  const COPYLEFT_PATTERN = /\b(AGPL|LGPL|GPL|MPL|EPL|EUPL|CDDL)\b/i;
4
5
 
@@ -43,16 +44,20 @@ export function formatNoticeSections(rows) {
43
44
  });
44
45
  }
45
46
 
46
- export function formatNoticesMarkdown(rows) {
47
+ // meta, the package that publishes the artifact (packageMeta), adds the relinking steps an LGPL library asks for.
48
+ export function formatNoticesMarkdown(rows, meta = null) {
47
49
  const sections = formatNoticeSections(rows);
50
+ const relinked = meta?.repository ? lgplRows(rows) : [];
48
51
 
49
52
  return [
50
53
  '# Third-Party Notices',
51
54
  '',
52
- 'GENERATED by `crossbind licenses` from recipe metadata (contract D/E) - do not edit.',
55
+ 'GENERATED by `crossbind licenses` - do not edit. This file states facts and is not legal advice; the',
56
+ 'compliance assessment is yours.',
53
57
  '',
54
58
  'This artifact bundles the following native libraries through crossbind packages.',
55
59
  '',
60
+ ...(relinked.length ? [relinkSection({ ...meta, artifact: 'module', rows: relinked }), ''] : []),
56
61
  sections.join('\n\n'),
57
62
  '',
58
63
  ].join('\n');
@@ -89,11 +94,13 @@ export function formatCycloneDxSbom(rows, target = {}) {
89
94
  },
90
95
  components: [...rows]
91
96
  .sort((a, b) => a.name.localeCompare(b.name))
92
- .map((row) => ({
97
+ .map((row) => ({ row, version: row.nativeVersion ?? row.version }))
98
+ .map(({ row, version }) => ({
93
99
  type: 'library',
94
100
  name: row.name,
95
- ...(row.nativeVersion ? { version: row.nativeVersion } : {}),
96
- ...(row.nativeVersion ? { purl: row.purl ?? `pkg:generic/${row.name}@${row.nativeVersion}` } : {}),
101
+ ...(version ? { version } : {}),
102
+ ...(version ? { purl: row.purl ?? `pkg:generic/${row.name}@${version}` } : {}),
103
+ ...(row.cpe ? { cpe: row.cpe } : {}),
97
104
  ...(row.license ? { licenses: [{ expression: row.license }] } : {}),
98
105
  ...(row.sha256 ? { hashes: [{ alg: 'SHA-256', content: row.sha256 }] } : {}),
99
106
  ...(row.sourceUrl ? { externalReferences: [{ type: 'distribution', url: row.sourceUrl }] } : {}),
@@ -1,14 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { deriveLicenseExpression, formatCycloneDxSbom, formatNoticeSections } from './licenseReport.js';
4
+ import { lgplRows, relinkSection } from './relinking.js';
5
+
6
+ export { lgplRows };
4
7
 
5
8
  export const SBOM_FILE = 'sbom.cdx.json';
6
9
  const RULE = '-'.repeat(70);
7
10
 
8
- export function lgplRows(rows) {
9
- return rows.filter((row) => /\bLGPL\b/.test(row.license ?? ''));
10
- }
11
-
12
11
  // The LLVM exception waives the notice for runtime parts compiled into a binary; every other
13
12
  // component needs its text in the package.
14
13
  function assertTexts(name, rows) {
@@ -22,36 +21,20 @@ function assertTexts(name, rows) {
22
21
  }
23
22
  }
24
23
 
25
- const listOf = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names[0]);
26
-
27
- function relinkSection({ name, version, repository, projectPath, addonFile, rows }) {
28
- const tag = `${name}@${version}`;
29
- const repo = repository.replace(/\.git$/, '');
30
- return [
31
- '## Relinking the LGPL libraries',
32
- '',
33
- `This addon links ${listOf(rows.map((row) => row.name))} statically; their sections below give their licenses.`,
34
- 'You may replace them with modified versions and relink the addon:',
35
- '',
36
- `1. Get the corresponding source. This package was built from ${repo} at the tag ${tag}:`,
37
- ` ${repo}/tree/${tag}`,
38
- ' The upstream sources are the tarballs the sections below list, with their SHA-256.',
39
- `2. In ${projectPath}, point the library at your modified source with a crossbind.overrides.js file`,
40
- ' (docs/playbooks/override-dependencies.md in that repository).',
41
- `3. Build the addon with \`pnpm install\` and \`pnpm --dir ${projectPath} build\`. Linux and Windows`,
42
- ' addons build in Docker, macOS addons on a Mac.',
43
- `4. The build writes ${addonFile} in ${projectPath}; it replaces the file of that name in this package.`,
44
- ].join('\n');
24
+ // What a Node-API package's license covers: an addon package what its addon links; a package without an addon crossbind's
25
+ // JavaScript runtime, the bindings of the project's own code and the data of the libraries that declare some.
26
+ export function shippedRows(rows, { addonFile }) {
27
+ return addonFile ? rows : rows.filter((row) => row.isRuntime || !row.isPort || row.shipsData);
45
28
  }
46
29
 
47
- export function nodePackageLicense({ name, version, repository, projectPath, addonFile = null, rows }) {
30
+ export function nodePackageLicense({ name, version, repository, projectPath, addonFile = null, sourceTag = null, rows }) {
48
31
  assertTexts(name, rows);
49
32
  const expression = deriveLicenseExpression(rows);
50
33
  const relinked = addonFile ? lgplRows(rows) : [];
51
34
  const lines = [
52
35
  addonFile
53
36
  ? `${name} ships a Node-API addon that links the components below statically.`
54
- : `${name} ships the JavaScript bindings, types and data of the components below; the addons that link them are in its platform packages.`,
37
+ : `${name} ships the JavaScript bindings, types and data of the components below. Each of its platform packages carries the LICENSE of the addon it holds.`,
55
38
  'The full license text of every component follows.',
56
39
  '',
57
40
  'Effective license expression (also package.json "license"):',
@@ -65,7 +48,9 @@ export function nodePackageLicense({ name, version, repository, projectPath, add
65
48
  '',
66
49
  RULE,
67
50
  '',
68
- ...(relinked.length ? [relinkSection({ name, version, repository, projectPath, addonFile, rows: relinked }), '', RULE, ''] : []),
51
+ ...(relinked.length ? [relinkSection({
52
+ name, version, repository, projectPath, sourceTag, artifact: 'addon', file: addonFile, rows: relinked,
53
+ }), '', RULE, ''] : []),
69
54
  formatNoticeSections(rows).join('\n\n'),
70
55
  '',
71
56
  ];
@@ -91,7 +76,7 @@ export function writeNodePackageLicense(dir, meta) {
91
76
 
92
77
  // What a Node-API package's license names, from its manifest and its place in the repository it builds
93
78
  // from: an addon package's is the addon its own build writes.
94
- export function nodePackageMeta({ package: manifest = {}, paths }) {
79
+ export function nodePackageMeta({ package: manifest = {}, paths, licenses }) {
95
80
  const repository = typeof manifest.repository === 'string' ? manifest.repository : (manifest.repository?.url ?? '');
96
81
  return {
97
82
  name: manifest.name,
@@ -99,5 +84,6 @@ export function nodePackageMeta({ package: manifest = {}, paths }) {
99
84
  repository,
100
85
  projectPath: path.relative(paths.base, paths.project).split(path.sep).join('/') || '.',
101
86
  addonFile: manifest.os && /\.node$/.test(manifest.main ?? '') ? manifest.main : null,
87
+ sourceTag: licenses?.sourceTag ?? null,
102
88
  };
103
89
  }
@@ -0,0 +1,50 @@
1
+ // What a package that links LGPL libraries statically tells its user: where the corresponding source is and how to
2
+ // build the artifact again with a modified library. A Node-API addon's LICENSE and a wasm module's notices both carry it.
3
+ export const OVERRIDE_PLAYBOOK = 'https://github.com/crossbind/crossbind/blob/main/docs/playbooks/override-dependencies.md';
4
+ export const DEFAULT_SOURCE_TAG = '{name}@{version}';
5
+
6
+ export function lgplRows(rows) {
7
+ return rows.filter((row) => /\bLGPL\b/.test(row.license ?? ''));
8
+ }
9
+
10
+ export const sourceTagOf = (format, name, version) => (format || DEFAULT_SOURCE_TAG)
11
+ .replaceAll('{name}', name)
12
+ .replaceAll('{version}', version);
13
+
14
+ const listOf = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names[0]);
15
+
16
+ const ARTIFACTS = {
17
+ addon: (file, projectPath) => ({
18
+ noun: 'addon',
19
+ verb: 'relink the addon',
20
+ where: 'Linux and Windows addons build in Docker, macOS addons on a Mac.',
21
+ result: `The build writes ${file} in ${projectPath}; it replaces the file of that name in this package.`,
22
+ }),
23
+ module: (_file, projectPath) => ({
24
+ noun: 'module',
25
+ verb: 'build the module again',
26
+ where: "The build runs in crossbind's toolchain: Docker, a remote runner or crossbind cloud.",
27
+ result: `The build writes the module's files in ${projectPath}/dist; they replace the files of those names in this package.`,
28
+ }),
29
+ };
30
+
31
+ export function relinkSection({ name, version, repository, projectPath, rows, sourceTag, artifact = 'addon', file = null }) {
32
+ const tag = sourceTagOf(sourceTag, name, version);
33
+ const repo = repository.replace(/\.git$/, '');
34
+ const { noun, verb, where, result } = ARTIFACTS[artifact](file, projectPath);
35
+ return [
36
+ '## Relinking the LGPL libraries',
37
+ '',
38
+ `This ${noun} links ${listOf(rows.map((row) => row.name))} statically; their sections below give their licenses.`,
39
+ `You may replace them with modified versions and ${verb}:`,
40
+ '',
41
+ `1. Get the corresponding source. This package was built from ${repo} at the tag ${tag}:`,
42
+ ` ${repo}/tree/${tag}`,
43
+ ' The upstream sources are the tarballs the sections below list, with their SHA-256.',
44
+ `2. In ${projectPath}, point the library at your modified source with a crossbind.overrides.js file`,
45
+ ` (${OVERRIDE_PLAYBOOK}).`,
46
+ `3. Build the packages the ${noun} links first, as their dist/ folders are not in git, then the ${noun} with`,
47
+ ` \`pnpm install\` and \`pnpm --dir ${projectPath} build\`. ${where}`,
48
+ `4. ${result}`,
49
+ ].join('\n');
50
+ }