diff --git a/package-lock.json b/package-lock.json index 3961e7b..5bd8db0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bytecodealliance/componentize-js", - "version": "0.19.0", + "version": "0.19.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bytecodealliance/componentize-js", - "version": "0.19.0", + "version": "0.19.1", "workspaces": [ "." ], @@ -3195,6 +3195,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3267,6 +3268,7 @@ "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -3380,6 +3382,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/src/cli.js b/src/cli.js index 39a1846..5294c3e 100755 --- a/src/cli.js +++ b/src/cli.js @@ -16,6 +16,7 @@ export async function componentizeCmd(jsSource, opts) { debugBindings: opts.debugBindings, debugBuild: opts.useDebugBuild, enableWizerLogging: opts.enableWizerLogging, + packageJsonPath: opts.ociPackageJson, }); await writeFile(opts.out, component); } @@ -43,6 +44,10 @@ program '--enable-wizer-logging', 'enable debug logging for calls in the generated component', ) + .option( + '--oci-package-json ', + 'path to package.json for OCI annotations (auto-detected if not specified)', + ) .requiredOption('-o, --out ', 'output component file') .action(asyncAction(componentizeCmd)); diff --git a/src/componentize.js b/src/componentize.js index 9991849..dfde2e8 100644 --- a/src/componentize.js +++ b/src/componentize.js @@ -21,6 +21,7 @@ import { import { splicer } from '../lib/spidermonkey-embedding-splicer.js'; import { maybeWindowsPath } from './platform.js'; +import { addOCIAnnotations, extractAnnotationsFromPackageJson } from './oci-annotations.js'; export const { version } = JSON.parse( await readFile(new URL('../package.json', import.meta.url), 'utf8'), @@ -104,6 +105,10 @@ export async function componentize( runtimeArgs, + // OCI Annotations options + ociAnnotations = undefined, // Can be an object with explicit annotations + packageJsonPath = undefined, // Path to package.json to extract annotations from + } = opts; debugBindings = debugBindings || debug?.bindings; @@ -338,7 +343,7 @@ export async function componentize( await writeFile('binary.wasm', finalBin); } - const component = await metadataAdd( + let component = await metadataAdd( await componentNew( finalBin, Object.entries({ @@ -352,6 +357,49 @@ export async function componentize( }), ); + // Add OCI annotations + let annotations = ociAnnotations || {}; + + // If no explicit annotations provided, try to load from package.json + if (Object.keys(annotations).length === 0) { + let packageJsonPath = packageJsonPath; + + // If no package.json path specified, try to find it relative to sourcePath + if (!packageJsonPath && sourcePath) { + const sourceDir = dirname(resolve(sourcePath)); + const candidatePath = join(sourceDir, 'package.json'); + if (existsSync(candidatePath)) { + packageJsonPath = candidatePath; + } + } + + // Try to load and extract annotations from package.json + if (packageJsonPath) { + try { + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + annotations = extractAnnotationsFromPackageJson(packageJson); + + if (debugBindings) { + console.error('--- OCI Annotations (from package.json) ---'); + console.error(annotations); + } + } catch (err) { + // If we can't read the package.json, just skip OCI annotations + if (debugBindings) { + console.error(`Note: Could not load package.json from ${packageJsonPath}: ${err.message}`); + } + } + } + } else if (debugBindings) { + console.error('--- OCI Annotations (explicit) ---'); + console.error(annotations); + } + + // Apply OCI annotations to the component + if (Object.keys(annotations).length > 0) { + component = addOCIAnnotations(component, annotations); + } + // Convert CABI import conventions to ESM import conventions imports = imports.map(([specifier, impt]) => specifier === '$root' ? [impt, 'default'] : [specifier, impt], diff --git a/src/oci-annotations.js b/src/oci-annotations.js new file mode 100644 index 0000000..00bd47f --- /dev/null +++ b/src/oci-annotations.js @@ -0,0 +1,197 @@ +/** + * OCI Annotations support for WebAssembly components + * + * This module implements the WebAssembly tool-conventions for OCI annotations + * as defined in: https://github.com/WebAssembly/tool-conventions/pull/248 + * + * The following custom sections are supported: + * - version: Version of the packaged software + * - description: Human-readable description of the binary + * - authors: Contact details of the authors + * - licenses: SPDX license expression + * - homepage: URL to find more information about the package + * - source: URL to get source code for building the package + * - revision: Hash of the commit used to build the package + */ + +/** + * Encode a number as LEB128 unsigned integer + * @param {number} value + * @returns {Uint8Array} + */ +function encodeLEB128(value) { + const bytes = []; + do { + let byte = value & 0x7f; + value >>= 7; + if (value !== 0) { + byte |= 0x80; + } + bytes.push(byte); + } while (value !== 0); + return new Uint8Array(bytes); +} + +/** + * Encode a custom section with the given name and data + * @param {string} name - Section name + * @param {string} data - Section data (UTF-8 string) + * @returns {Uint8Array} + */ +function encodeCustomSection(name, data) { + const nameBytes = new TextEncoder().encode(name); + const dataBytes = new TextEncoder().encode(data); + + const nameLengthBytes = encodeLEB128(nameBytes.length); + const payloadSize = nameLengthBytes.length + nameBytes.length + dataBytes.length; + const sectionSizeBytes = encodeLEB128(payloadSize); + + // Custom section ID is 0 + const sectionId = new Uint8Array([0]); + + // Concatenate all parts + const section = new Uint8Array( + sectionId.length + + sectionSizeBytes.length + + nameLengthBytes.length + + nameBytes.length + + dataBytes.length + ); + + let offset = 0; + section.set(sectionId, offset); + offset += sectionId.length; + section.set(sectionSizeBytes, offset); + offset += sectionSizeBytes.length; + section.set(nameLengthBytes, offset); + offset += nameLengthBytes.length; + section.set(nameBytes, offset); + offset += nameBytes.length; + section.set(dataBytes, offset); + + return section; +} + +/** + * Add OCI annotation custom sections to a WebAssembly component + * @param {Uint8Array} component - The WebAssembly component binary + * @param {Object} annotations - OCI annotations to add + * @param {string} [annotations.version] - Package version + * @param {string} [annotations.description] - Package description + * @param {string} [annotations.authors] - Package authors (freeform contact details) + * @param {string} [annotations.licenses] - SPDX license expression + * @param {string} [annotations.homepage] - Package homepage URL + * @param {string} [annotations.source] - Source repository URL + * @param {string} [annotations.revision] - Source revision/commit hash + * @returns {Uint8Array} - Component with OCI annotations added + */ +export function addOCIAnnotations(component, annotations) { + if (!annotations || Object.keys(annotations).length === 0) { + return component; + } + + const sections = []; + + // Add each annotation as a custom section + // Order matters: add in a consistent order for reproducibility + const fields = ['version', 'description', 'authors', 'licenses', 'homepage', 'source', 'revision']; + + for (const field of fields) { + if (annotations[field]) { + sections.push(encodeCustomSection(field, annotations[field])); + } + } + + if (sections.length === 0) { + return component; + } + + // Calculate total size needed + let totalSize = component.length; + for (const section of sections) { + totalSize += section.length; + } + + // The WebAssembly module/component starts with a magic number and version + // Magic: 0x00 0x61 0x73 0x6d (for modules) + // Component magic: 0x00 0x61 0x73 0x6d (same magic, different layer encoding) + // Version: 4 bytes + // We want to insert custom sections after the header (8 bytes) + + const WASM_HEADER_SIZE = 8; + const result = new Uint8Array(totalSize); + + // Copy header + result.set(component.subarray(0, WASM_HEADER_SIZE), 0); + + let offset = WASM_HEADER_SIZE; + + // Add custom sections + for (const section of sections) { + result.set(section, offset); + offset += section.length; + } + + // Copy rest of component + result.set(component.subarray(WASM_HEADER_SIZE), offset); + + return result; +} + +/** + * Extract OCI annotations from package.json metadata + * @param {Object} packageJson - Parsed package.json content + * @returns {Object} OCI annotations + */ +export function extractAnnotationsFromPackageJson(packageJson) { + const annotations = {}; + + if (packageJson.version) { + annotations.version = packageJson.version; + } + + if (packageJson.description) { + annotations.description = packageJson.description; + } + + // Authors can be a string or an array of objects/strings + if (packageJson.author) { + if (typeof packageJson.author === 'string') { + annotations.authors = packageJson.author; + } else if (packageJson.author.name) { + const author = packageJson.author; + let authorStr = author.name; + if (author.email) authorStr += ` <${author.email}>`; + annotations.authors = authorStr; + } + } else if (packageJson.authors && Array.isArray(packageJson.authors)) { + const authorStrs = packageJson.authors.map(a => { + if (typeof a === 'string') return a; + let str = a.name || ''; + if (a.email) str += ` <${a.email}>`; + return str; + }).filter(s => s); + if (authorStrs.length > 0) { + annotations.authors = authorStrs.join(', '); + } + } + + if (packageJson.license) { + annotations.licenses = packageJson.license; + } + + if (packageJson.homepage) { + annotations.homepage = packageJson.homepage; + } + + // Extract source URL from repository field + if (packageJson.repository) { + if (typeof packageJson.repository === 'string') { + annotations.source = packageJson.repository; + } else if (packageJson.repository.url) { + annotations.source = packageJson.repository.url; + } + } + + return annotations; +} diff --git a/test/oci-annotations.test.js b/test/oci-annotations.test.js new file mode 100644 index 0000000..d46281c --- /dev/null +++ b/test/oci-annotations.test.js @@ -0,0 +1,180 @@ +import { describe, it } from 'vitest'; +import { strictEqual, deepStrictEqual } from 'node:assert'; +import { + addOCIAnnotations, + extractAnnotationsFromPackageJson, +} from '../src/oci-annotations.js'; + +describe('OCI Annotations', () => { + describe('extractAnnotationsFromPackageJson', () => { + it('should extract version from package.json', () => { + const packageJson = { + version: '1.0.0', + }; + const annotations = extractAnnotationsFromPackageJson(packageJson); + strictEqual(annotations.version, '1.0.0'); + }); + + it('should extract description from package.json', () => { + const packageJson = { + description: 'A test package', + }; + const annotations = extractAnnotationsFromPackageJson(packageJson); + strictEqual(annotations.description, 'A test package'); + }); + + it('should extract string author from package.json', () => { + const packageJson = { + author: 'John Doe ', + }; + const annotations = extractAnnotationsFromPackageJson(packageJson); + strictEqual(annotations.authors, 'John Doe '); + }); + + it('should extract object author from package.json', () => { + const packageJson = { + author: { + name: 'John Doe', + email: 'john@example.com', + }, + }; + const annotations = extractAnnotationsFromPackageJson(packageJson); + strictEqual(annotations.authors, 'John Doe '); + }); + + it('should extract authors array from package.json', () => { + const packageJson = { + authors: [ + { name: 'John Doe', email: 'john@example.com' }, + 'Jane Smith ', + ], + }; + const annotations = extractAnnotationsFromPackageJson(packageJson); + strictEqual( + annotations.authors, + 'John Doe , Jane Smith ' + ); + }); + + it('should extract license from package.json', () => { + const packageJson = { + license: 'MIT', + }; + const annotations = extractAnnotationsFromPackageJson(packageJson); + strictEqual(annotations.licenses, 'MIT'); + }); + + it('should extract homepage from package.json', () => { + const packageJson = { + homepage: 'https://example.com', + }; + const annotations = extractAnnotationsFromPackageJson(packageJson); + strictEqual(annotations.homepage, 'https://example.com'); + }); + + it('should extract repository URL string from package.json', () => { + const packageJson = { + repository: 'https://github.com/user/repo', + }; + const annotations = extractAnnotationsFromPackageJson(packageJson); + strictEqual(annotations.source, 'https://github.com/user/repo'); + }); + + it('should extract repository URL object from package.json', () => { + const packageJson = { + repository: { + type: 'git', + url: 'https://github.com/user/repo.git', + }, + }; + const annotations = extractAnnotationsFromPackageJson(packageJson); + strictEqual(annotations.source, 'https://github.com/user/repo.git'); + }); + + it('should extract all fields from package.json', () => { + const packageJson = { + version: '2.1.0', + description: 'Full package', + author: 'Author Name', + license: 'Apache-2.0', + homepage: 'https://homepage.com', + repository: 'https://github.com/user/repo', + }; + const annotations = extractAnnotationsFromPackageJson(packageJson); + deepStrictEqual(annotations, { + version: '2.1.0', + description: 'Full package', + authors: 'Author Name', + licenses: 'Apache-2.0', + homepage: 'https://homepage.com', + source: 'https://github.com/user/repo', + }); + }); + }); + + describe('addOCIAnnotations', () => { + it('should return original component if no annotations', () => { + // Create a minimal WebAssembly component header + const component = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x0d, 0x00, 0x01, 0x00, // version (component layer) + 0x01, 0x03, 0x00, 0x01, 0x00, // minimal section + ]); + const result = addOCIAnnotations(component, {}); + strictEqual(result, component); + }); + + it('should add version custom section', () => { + const component = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x0d, 0x00, 0x01, 0x00, // version (component layer) + ]); + const result = addOCIAnnotations(component, { version: '1.0.0' }); + + // Verify magic and version are preserved + strictEqual(result[0], 0x00); + strictEqual(result[1], 0x61); + strictEqual(result[2], 0x73); + strictEqual(result[3], 0x6d); + strictEqual(result[4], 0x0d); + strictEqual(result[5], 0x00); + strictEqual(result[6], 0x01); + strictEqual(result[7], 0x00); + + // Custom section should be added after header + // Section ID 0 for custom section + strictEqual(result[8], 0x00); + + // Verify the result is larger than original + strictEqual(result.length > component.length, true); + }); + + it('should add multiple annotations', () => { + const component = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x0d, 0x00, 0x01, 0x00, // version + ]); + const result = addOCIAnnotations(component, { + version: '1.0.0', + description: 'Test', + }); + + // Should be significantly larger due to two custom section entries + strictEqual(result.length > component.length + 20, true); + }); + + it('should preserve component content after custom sections', () => { + const component = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x0d, 0x00, 0x01, 0x00, // version + 0x01, 0x03, 0x00, 0x01, 0x00, // some content + ]); + const result = addOCIAnnotations(component, { version: '1.0.0' }); + + // Last 5 bytes should be preserved + const originalEnd = Array.from(component.slice(-5)); + const resultEnd = Array.from(result.slice(-5)); + deepStrictEqual(resultEnd, originalEnd); + }); + }); +});