Skip to content

Commit e268ec7

Browse files
ricochetvados-cosmonic
authored andcommitted
feat: add OCI annotations
Fixes #301
1 parent 3803af4 commit e268ec7

File tree

5 files changed

+436
-3
lines changed

5 files changed

+436
-3
lines changed

package-lock.json

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export async function componentizeCmd(jsSource, opts) {
1616
debugBindings: opts.debugBindings,
1717
debugBuild: opts.useDebugBuild,
1818
enableWizerLogging: opts.enableWizerLogging,
19+
packageJsonPath: opts.ociPackageJson,
1920
});
2021
await writeFile(opts.out, component);
2122
}
@@ -43,6 +44,10 @@ program
4344
'--enable-wizer-logging',
4445
'enable debug logging for calls in the generated component',
4546
)
47+
.option(
48+
'--oci-package-json <path>',
49+
'path to package.json for OCI annotations (auto-detected if not specified)',
50+
)
4651
.requiredOption('-o, --out <out>', 'output component file')
4752
.action(asyncAction(componentizeCmd));
4853

src/componentize.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { splicer } from '../lib/spidermonkey-embedding-splicer.js';
2222

2323
import { maybeWindowsPath } from './platform.js';
24+
import { addOCIAnnotations, extractAnnotationsFromPackageJson } from './oci-annotations.js';
2425

2526
export const { version } = JSON.parse(
2627
await readFile(new URL('../package.json', import.meta.url), 'utf8'),
@@ -104,6 +105,10 @@ export async function componentize(
104105

105106
runtimeArgs,
106107

108+
// OCI Annotations options
109+
ociAnnotations = undefined, // Can be an object with explicit annotations
110+
packageJsonPath = undefined, // Path to package.json to extract annotations from
111+
107112
} = opts;
108113

109114
debugBindings = debugBindings || debug?.bindings;
@@ -338,7 +343,7 @@ export async function componentize(
338343
await writeFile('binary.wasm', finalBin);
339344
}
340345

341-
const component = await metadataAdd(
346+
let component = await metadataAdd(
342347
await componentNew(
343348
finalBin,
344349
Object.entries({
@@ -352,6 +357,49 @@ export async function componentize(
352357
}),
353358
);
354359

360+
// Add OCI annotations
361+
let annotations = ociAnnotations || {};
362+
363+
// If no explicit annotations provided, try to load from package.json
364+
if (Object.keys(annotations).length === 0) {
365+
let packageJsonPath = packageJsonPath;
366+
367+
// If no package.json path specified, try to find it relative to sourcePath
368+
if (!packageJsonPath && sourcePath) {
369+
const sourceDir = dirname(resolve(sourcePath));
370+
const candidatePath = join(sourceDir, 'package.json');
371+
if (existsSync(candidatePath)) {
372+
packageJsonPath = candidatePath;
373+
}
374+
}
375+
376+
// Try to load and extract annotations from package.json
377+
if (packageJsonPath) {
378+
try {
379+
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
380+
annotations = extractAnnotationsFromPackageJson(packageJson);
381+
382+
if (debugBindings) {
383+
console.error('--- OCI Annotations (from package.json) ---');
384+
console.error(annotations);
385+
}
386+
} catch (err) {
387+
// If we can't read the package.json, just skip OCI annotations
388+
if (debugBindings) {
389+
console.error(`Note: Could not load package.json from ${packageJsonPath}: ${err.message}`);
390+
}
391+
}
392+
}
393+
} else if (debugBindings) {
394+
console.error('--- OCI Annotations (explicit) ---');
395+
console.error(annotations);
396+
}
397+
398+
// Apply OCI annotations to the component
399+
if (Object.keys(annotations).length > 0) {
400+
component = addOCIAnnotations(component, annotations);
401+
}
402+
355403
// Convert CABI import conventions to ESM import conventions
356404
imports = imports.map(([specifier, impt]) =>
357405
specifier === '$root' ? [impt, 'default'] : [specifier, impt],

src/oci-annotations.js

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* OCI Annotations support for WebAssembly components
3+
*
4+
* This module implements the WebAssembly tool-conventions for OCI annotations
5+
* as defined in: https://github.com/WebAssembly/tool-conventions/pull/248
6+
*
7+
* The following custom sections are supported:
8+
* - version: Version of the packaged software
9+
* - description: Human-readable description of the binary
10+
* - authors: Contact details of the authors
11+
* - licenses: SPDX license expression
12+
* - homepage: URL to find more information about the package
13+
* - source: URL to get source code for building the package
14+
* - revision: Hash of the commit used to build the package
15+
*/
16+
17+
/**
18+
* Encode a number as LEB128 unsigned integer
19+
* @param {number} value
20+
* @returns {Uint8Array}
21+
*/
22+
function encodeLEB128(value) {
23+
const bytes = [];
24+
do {
25+
let byte = value & 0x7f;
26+
value >>= 7;
27+
if (value !== 0) {
28+
byte |= 0x80;
29+
}
30+
bytes.push(byte);
31+
} while (value !== 0);
32+
return new Uint8Array(bytes);
33+
}
34+
35+
/**
36+
* Encode a custom section with the given name and data
37+
* @param {string} name - Section name
38+
* @param {string} data - Section data (UTF-8 string)
39+
* @returns {Uint8Array}
40+
*/
41+
function encodeCustomSection(name, data) {
42+
const nameBytes = new TextEncoder().encode(name);
43+
const dataBytes = new TextEncoder().encode(data);
44+
45+
const nameLengthBytes = encodeLEB128(nameBytes.length);
46+
const payloadSize = nameLengthBytes.length + nameBytes.length + dataBytes.length;
47+
const sectionSizeBytes = encodeLEB128(payloadSize);
48+
49+
// Custom section ID is 0
50+
const sectionId = new Uint8Array([0]);
51+
52+
// Concatenate all parts
53+
const section = new Uint8Array(
54+
sectionId.length +
55+
sectionSizeBytes.length +
56+
nameLengthBytes.length +
57+
nameBytes.length +
58+
dataBytes.length
59+
);
60+
61+
let offset = 0;
62+
section.set(sectionId, offset);
63+
offset += sectionId.length;
64+
section.set(sectionSizeBytes, offset);
65+
offset += sectionSizeBytes.length;
66+
section.set(nameLengthBytes, offset);
67+
offset += nameLengthBytes.length;
68+
section.set(nameBytes, offset);
69+
offset += nameBytes.length;
70+
section.set(dataBytes, offset);
71+
72+
return section;
73+
}
74+
75+
/**
76+
* Add OCI annotation custom sections to a WebAssembly component
77+
* @param {Uint8Array} component - The WebAssembly component binary
78+
* @param {Object} annotations - OCI annotations to add
79+
* @param {string} [annotations.version] - Package version
80+
* @param {string} [annotations.description] - Package description
81+
* @param {string} [annotations.authors] - Package authors (freeform contact details)
82+
* @param {string} [annotations.licenses] - SPDX license expression
83+
* @param {string} [annotations.homepage] - Package homepage URL
84+
* @param {string} [annotations.source] - Source repository URL
85+
* @param {string} [annotations.revision] - Source revision/commit hash
86+
* @returns {Uint8Array} - Component with OCI annotations added
87+
*/
88+
export function addOCIAnnotations(component, annotations) {
89+
if (!annotations || Object.keys(annotations).length === 0) {
90+
return component;
91+
}
92+
93+
const sections = [];
94+
95+
// Add each annotation as a custom section
96+
// Order matters: add in a consistent order for reproducibility
97+
const fields = ['version', 'description', 'authors', 'licenses', 'homepage', 'source', 'revision'];
98+
99+
for (const field of fields) {
100+
if (annotations[field]) {
101+
sections.push(encodeCustomSection(field, annotations[field]));
102+
}
103+
}
104+
105+
if (sections.length === 0) {
106+
return component;
107+
}
108+
109+
// Calculate total size needed
110+
let totalSize = component.length;
111+
for (const section of sections) {
112+
totalSize += section.length;
113+
}
114+
115+
// The WebAssembly module/component starts with a magic number and version
116+
// Magic: 0x00 0x61 0x73 0x6d (for modules)
117+
// Component magic: 0x00 0x61 0x73 0x6d (same magic, different layer encoding)
118+
// Version: 4 bytes
119+
// We want to insert custom sections after the header (8 bytes)
120+
121+
const WASM_HEADER_SIZE = 8;
122+
const result = new Uint8Array(totalSize);
123+
124+
// Copy header
125+
result.set(component.subarray(0, WASM_HEADER_SIZE), 0);
126+
127+
let offset = WASM_HEADER_SIZE;
128+
129+
// Add custom sections
130+
for (const section of sections) {
131+
result.set(section, offset);
132+
offset += section.length;
133+
}
134+
135+
// Copy rest of component
136+
result.set(component.subarray(WASM_HEADER_SIZE), offset);
137+
138+
return result;
139+
}
140+
141+
/**
142+
* Extract OCI annotations from package.json metadata
143+
* @param {Object} packageJson - Parsed package.json content
144+
* @returns {Object} OCI annotations
145+
*/
146+
export function extractAnnotationsFromPackageJson(packageJson) {
147+
const annotations = {};
148+
149+
if (packageJson.version) {
150+
annotations.version = packageJson.version;
151+
}
152+
153+
if (packageJson.description) {
154+
annotations.description = packageJson.description;
155+
}
156+
157+
// Authors can be a string or an array of objects/strings
158+
if (packageJson.author) {
159+
if (typeof packageJson.author === 'string') {
160+
annotations.authors = packageJson.author;
161+
} else if (packageJson.author.name) {
162+
const author = packageJson.author;
163+
let authorStr = author.name;
164+
if (author.email) authorStr += ` <${author.email}>`;
165+
annotations.authors = authorStr;
166+
}
167+
} else if (packageJson.authors && Array.isArray(packageJson.authors)) {
168+
const authorStrs = packageJson.authors.map(a => {
169+
if (typeof a === 'string') return a;
170+
let str = a.name || '';
171+
if (a.email) str += ` <${a.email}>`;
172+
return str;
173+
}).filter(s => s);
174+
if (authorStrs.length > 0) {
175+
annotations.authors = authorStrs.join(', ');
176+
}
177+
}
178+
179+
if (packageJson.license) {
180+
annotations.licenses = packageJson.license;
181+
}
182+
183+
if (packageJson.homepage) {
184+
annotations.homepage = packageJson.homepage;
185+
}
186+
187+
// Extract source URL from repository field
188+
if (packageJson.repository) {
189+
if (typeof packageJson.repository === 'string') {
190+
annotations.source = packageJson.repository;
191+
} else if (packageJson.repository.url) {
192+
annotations.source = packageJson.repository.url;
193+
}
194+
}
195+
196+
return annotations;
197+
}

0 commit comments

Comments
 (0)