@opys/bundle
The .opys file: a manifest and the files it carries, as one zip. @opys/bundle writes one and reads one back. The manifest inside is @opys/core's.
npm install @opys/bundleExample
Write a bundle, then read it back.
import {
blobFile,
hashBlobFile,
readBundle,
readBundleHead,
writeBundle,
} from '@opys/bundle';
// a carried file is named by the sha256 of its content
const { id, size } = await hashBlobFile('./server.properties');
const manifest = {
vars: { root: '/games/pack' },
artifacts: [
{ path: '${root}/server.properties', source: { blob: id }, size },
{
path: '${root}/libs/a.jar',
source: { url: 'https://example.com/a.jar' },
integrity: { sha1: 'da39a3ee5e6b4b0d3255bfef95601890afd80709' },
},
],
};
await writeBundle('pack.opys', manifest, {
[id]: blobFile('./server.properties'), // where the blob's bytes are
});
readBundleHead('pack.opys'); // { format: 1 }
readBundle('pack.opys'); // the manifestThe format
Every structure of a bundle: what it is, its fields, and how it is written.
Bundle a zip
├── opys.json Head
├── manifest.json Manifest
└── blobs/<sha256> the carried filesBundle
How a manifest is published: one zip, usually named .opys.
| Entry | What it is |
|---|---|
opys.json | The Head. |
manifest.json | The Manifest, whole, as @opys/core describes it. |
blobs/<sha256> | One per carried file, named by the hash of its content. |
pack.opys
├── opys.json
├── manifest.json
└── blobs/
└── 3d862eef2acd67a2dcb60351bda23e6ad7ebd51939b393eede30ec140ba3c20d- A bundle that names a blob it does not hold is refused before anything is installed.
- Two builds of the same manifest give the same bytes.
- Any zip tool reads one:
unzip -p pack.opys manifest.json.
Head
What a bundle says about itself, apart from the installation it carries.
| Field | Type | What it is |
|---|---|---|
format | number | The format version: 1. |
locale? | string | The language the options are written in. |
options? | OptionDef[] | What a player may set. See Options. |
// opys.json
{
"format": 1
}- A reader refuses any
formatbut its own, an older one as much as a newer, before it reads the manifest. - Nothing of the manifest is in the head. Variables, the launch command and cleanup rules are in
manifest.json. - The head is written first and uncompressed, so it is readable without unpacking the rest.
Options
What whoever launches the bundle may choose, in the head. Each option fills a variable, or switches a feature, that the manifest already reads.
const written = options()
.slider('xmx', {
title: 'RAM',
unit: 'MB',
min: 1024, max: 16384, step: 512, default: 4096,
})
.select('preset', { title: 'Graphics', choices: { low: 'Low', high: 'High' } })
.text('server', { title: 'Server', placeholder: 'play.example.net' })
.file('skin', { title: 'Skin' })
.feature('custom_java', {
title: 'Custom Java',
options: options().directory('java_home', { title: 'Java folder' }),
});Each kind adds an option: the name it fills, then its fields in one object.
| Kind | Names | Needs | May have |
|---|---|---|---|
.slider(name, fields) | a variable | min, max, step, default | unit |
.select(name, fields) | a variable | choices, as { value: label } | default |
.text(name, fields) | a variable | placeholder, default | |
.file(name, fields) | a variable | ||
.directory(name, fields) | a variable | ||
.feature(name, fields) | a feature | default, options |
Every kind also takes title, which it needs, and subtitle. A title left out is a type error, and writeBundle refuses a head without one.
Each kind is a function of its own too, for a list:
const written = [
slider('xmx', { title: 'RAM', min: 1024, max: 16384, step: 512, default: 4096 }),
feature('custom_java', {
title: 'Custom Java',
options: [directory('java_home', { title: 'Java folder' })],
}),
];In the head an option is told apart by the field that holds its name:
{ "slider": "xmx", "title": "RAM", "min": 1024, "max": 16384, "step": 512, "default": 4096, "unit": "MB" }
{ "select": "preset", "title": "Graphics", "choices": [{ "value": "low", "label": "Low" }, { "value": "high", "label": "High" }], "default": "low" }
{ "feature": "custom_java", "title": "Custom Java", "options": [{ "directory": "java_home", "title": "Java folder" }] }- A chain is a value. Every step returns a new one.
- The options under a feature only matter while it is on. They nest to any depth.
- A select starts on its first choice unless
defaultsays otherwise. Choices keep the order written, except values that look like whole numbers, which JavaScript lists first. - A name is one option: a variable or a feature named twice is refused.
- A slider's
defaultis insidemintomax. - This is a schema only. What a player chose reaches an install as
varsandfeatures.
Translations. title, subtitle, unit, placeholder and a choice's label are each a string or language to text, the type Localized:
await writeBundle('pack.opys', manifest, blobs, {
locale: 'en',
options: options().slider('xmx', {
title: { en: 'RAM', uk: "Пам'ять" },
min: 1024, max: 16384, step: 512, default: 4096,
}),
});- A plain string is in the head's
locale, a tag such asenorpt-BR. - A translation must hold
locale. One without it, or any translation in a head with nolocale, is refused. - Names, choice values and defaults are never translated.
- Which language to show is the launcher's choice: the player's, else
locale.
Manifest
The entry manifest.json: every file to install, how to start the game, and what to delete. It is exactly what decodeManifest of @opys/core reads, with no field of the bundle's own.
// manifest.json
{
"vars": { "root": "/games/pack" },
"artifacts": [
{
"path": "${root}/server.properties",
"source": { "blob": "3d862eef2acd67a2dcb60351bda23e6ad7ebd51939b393eede30ec140ba3c20d" },
"size": 11
}
],
"launch": { "command": "java", "workdir": "${root}" },
"cleanup": [{ "includes": ["${root}/mods/*.jar"] }]
}Blobs
Not part of the format: where each blob's bytes are before they are in a bundle. A map from blob id to { file } or { bytes } (base64), handed to writeBundle. A pack author never writes one: opys build makes it from the artifacts whose source is a file or bytes.
{ '3d862eef…': { file: './server.properties' } }Blob
A file carried in the bundle. An artifact names it as { "blob": "<sha256>" } and its bytes are the entry blobs/<sha256>.
- The name is the sha256 of the content, as 64 lowercase hex digits.
- A blob two artifacts use is stored once.
- Each blob is checked against its name when it is written and again when it is installed.
Functions
| Function | What it does |
|---|---|
writeBundle(path, manifest, blobs?, head?) | Writes a bundle. Refuses a blob it was not given. |
readBundle(path) | The manifest of a bundle. |
readBundleHead(path) | The head alone. One small read. |
options(), slider, select, … | The options of a head. See Options. |
optionDefs(options) | Options as the head spells them. |
hashBlobFile(path), blobId(bytes) | The id a carried file goes by. |
blobFile(path), blobBytes(bytes) | Where a blob's bytes are, for writeBundle. |
BUNDLE_FORMAT | 1. |
Every function
Each function, with what it returns.
await writeBundle('pack.opys', manifest, { [id]: blobFile('./server.properties') });
await writeBundle('pack.opys', manifest, { [id]: blobBytes(bytes) }); // bytes made in memory
await writeBundle('pack.opys', { vars: {}, artifacts: [] }); // no carried files
await writeBundle('pack.opys', manifest, {});
// rejects: the manifest names blob 3d862eef…, and nothing holds it
await writeBundle('pack.opys', manifest, blobs, { options: options().file('a', { title: 'A' }) });
await writeBundle('pack.opys', manifest, blobs, { options: [{ file: 'a' }] });
// rejects: missing field `title`
await writeBundle('pack.opys', manifest, blobs, { options: [file('a', { title: { uk: 'А' } })] });
// rejects: a text is translated (`uk`), and the head names no locale to fall back to
optionDefs(options().file('a', { title: 'A' })); // [{ file: 'a', title: 'A' }]
await hashBlobFile('./server.properties'); // { id: '3d862eef…', size: 11 }
blobId(new TextEncoder().encode('hello')); // '2cf24dba…'
readBundleHead('pack.opys'); // { format: 1, locale?, options? }
readBundle('pack.opys'); // { vars, artifacts, launch, cleanup }
readBundle('notes.txt'); // throws: not a bundle: …
BUNDLE_FORMAT; // 1writeBundle writes beside the destination and moves the file into place, so a failed write leaves the previous bundle as it was.
Documentation
- The bundle: what it is and why
- The bundle file: the layout, for another reader or writer
- The format: the manifest inside
Part of opys.