Skip to content

@opys/bundle ​

npm

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.

sh
npm install @opys/bundle

Example ​

Write a bundle, then read it back.

js
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 manifest

The format ​

Every structure of a bundle: what it is, its fields, and how it is written.

text
Bundle                 a zip
├── opys.json          Head
├── manifest.json      Manifest
└── blobs/<sha256>     the carried files

Bundle ​

How a manifest is published: one zip, usually named .opys.

EntryWhat it is
opys.jsonThe Head.
manifest.jsonThe Manifest, whole, as @opys/core describes it.
blobs/<sha256>One per carried file, named by the hash of its content.
text
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.

What a bundle says about itself, apart from the installation it carries.

FieldTypeWhat it is
formatnumberThe format version: 1.
locale?stringThe language the options are written in.
options?OptionDef[]What a player may set. See Options.
jsonc
// opys.json
{
  "format": 1
}
  • A reader refuses any format but 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.

js
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.

KindNamesNeedsMay have
.slider(name, fields)a variablemin, max, step, defaultunit
.select(name, fields)a variablechoices, as { value: label }default
.text(name, fields)a variableplaceholder, default
.file(name, fields)a variable
.directory(name, fields)a variable
.feature(name, fields)a featuredefault, 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:

js
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:

jsonc
{ "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 default says 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 default is inside min to max.
  • This is a schema only. What a player chose reaches an install as vars and features.

Translations. title, subtitle, unit, placeholder and a choice's label are each a string or language to text, the type Localized:

js
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 as en or pt-BR.
  • A translation must hold locale. One without it, or any translation in a head with no locale, 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.

jsonc
// 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.

js
{ '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 ​

FunctionWhat 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_FORMAT1.

Every function ​

Each function, with what it returns.

js
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;               // 1

writeBundle writes beside the destination and moves the file into place, so a failed write leaves the previous bundle as it was.

Documentation ​

Part of opys.