npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@okonomi-gmbh/opm-loader

v0.1.1

Published

Build parametric windows, doors and switch ranges from a .opm file: a sandboxed Lua VM and a geometry kernel, in the page or in Node.

Readme

@okonomi-gmbh/opm-loader

A .opm is a parametric model: a manifest saying what it builds and which inputs it takes, a Lua program that builds it, and optionally a drawing and a set of finishes. This package opens one, runs its program in a sandboxed Lua VM, and hands back named parts as flat arrays.

npm install @okonomi-gmbh/opm-loader
import { fetchOpm, toBuildResult } from '@okonomi-gmbh/opm-loader'
import wasmUri from '@okonomi-gmbh/opm-loader/glue.wasm?url'   // Vite; see below

const opm = await fetchOpm('/programs/window.opm', { wasmUri })
const built = toBuildResult(opm.build('window_casement', { width: 900, height: 1400 }))

for (const part of built.parts) {
  part.name        // the movable node it belongs to — 'frame', 'sash', …
  part.material    // the slot to dress it in
  part.positions   // number[], metres
  part.normals     // number[]
  part.uvs         // number[], metres — set repeat = 1 / worldSize
  part.indices     // number[], triangles
}

Also on the result: bounds, ports (where a handle goes), pivots, animations (how it opens) and warnings.

That is the whole of the core. No renderer, no glTF, no scene graph — the only dependency on that path is the Lua VM. What you do with the arrays is your business, and there is more than one right answer.


Two things you cannot skip

1. Say where the WebAssembly is

wasmUri is the one option with no sensible default. Left unset, the Lua VM decides it is in a browser and fetches its glue.wasm from unpkg.com — a third-party dependency your page acquires on its first build, and on a page with a content policy, a build that simply does not happen.

The file ships with this package. Three spellings, because no one of them works everywhere:

// Vite, Rollup
import wasmUri from '@okonomi-gmbh/opm-loader/glue.wasm?url'

// Webpack 5, Rspack — asset modules
const wasmUri = new URL('@okonomi-gmbh/opm-loader/glue.wasm', import.meta.url).href

// Anything else: copy the file into your public directory at build time and
// point at it. It is at `dist/glue.wasm` inside the package.

It is shipped rather than pointed at because node_modules/wasmoon/dist/glue.wasm is not a stable path: whether it is hoisted depends on your package manager, and under pnpm it is not there at all. The copy is pinned to the exact VM version this package depends on — the glue and the wasm are one build and cannot be mixed.

2. Let your page instantiate WebAssembly

A Content-Security-Policy with an explicit script-src blocks WebAssembly.instantiate unless it says so. This is the most likely reason a build that works in development does nothing in production, and the error names neither this package nor the VM.

# Rails, config/initializers/content_security_policy.rb
policy.script_src :self, :wasm_unsafe_eval
Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'

wasm-unsafe-eval permits WebAssembly compilation and nothing else — it does not re-enable eval for JavaScript.


Where a .opm comes from

fetchOpm takes either form, and tells them apart by looking at the bytes rather than by trusting a content type — .opm is registered with nobody, so one host serves it as application/octet-stream, another as text/plain.

await fetchOpm('/programs/window.opm')     // the artefact: one archive, one request
await fetchOpm('/programs/window.opm/')    // the authoring form: four files beside each other

If you already have the bytes — a drop zone, an <input type=file>, a file read in Node — hand them over directly:

import { openOpmArchiveInBrowser } from '@okonomi-gmbh/opm-loader'

const opm = await openOpmArchiveInBrowser(bytes, 'window.opm', { wasmUri })

That is also why there is no Node entry point. Nothing here touches a filesystem: the entry uses fetch, TextEncoder and CompressionStream, all of which Node 22 has. Read the file yourself and pass the bytes.

If you fetch from a generator service, note it will need your origin in its CORS allowlist — an empty allowlist sends no headers at all.


What is in a file, without building anything

fetchOpm gives back a LoadedOpm, and everything descriptive on it is readable at once — it is the manifest, read when the file was opened. No build, no Lua, no waiting. That is the difference from a build service, where you had to build something before you could learn what could be built.

const opm = await fetchOpm('/programs/window.opm', { wasmUri })

opm.manifest                     // the envelope
opm.generators                   // every Bauart the file offers
opm.generator('window_casement') // one of them, or a throw

Only three calls start the Lua build at all: build, plan and slots. Everything else is reading.

opm.manifest — who, and what

{
  format:  'okonomi-parametric@1',   // the container's version
  kernel:  'okonomi-mesh@1',         // which kernel version it was written against
  vendor:  { name: 'okonomi-archviz-generator', version: '6.0.0' },
  fieldSchema: 'field-schema@1',     // which field format the inputs speak
  generators: [ … ]                  // the same as opm.generators
}

opm.generators — one Bauart

A file is a Lieferprogramm and may carry several. window.opm carries four: window_fixed, window_casement, window_casement_double, window_transom.

{
  key:          'window_casement',
  type:         'window',                // the product family
  typeContract: 'okonomi/window@1',      // the id only — see below
  label:        { default: 'Window, single casement',
                  de: 'Fenster, einflügelig Dreh-Kipp' },
  description:  { default: '…', de: '…' },

  groups:     [ { id: 'size', order: 1, label: { default: 'Size', de: 'Maße' } }, … ],
  inputs:     [ … ],                     // see below
  slots:      [ { key: 'frame', label: {…} },                       // no default
                { key: 'glass', label: {…}, defaultMaterial: 'glass' }, … ],
  ports:      [ … ],                     // where a handle goes
  animations: [ … ]                      // how it opens
}

ports and animations are here rather than only in a build result. A host therefore knows before the first build whether a Bauart carries a handle and whether it opens at all — against a service, that answer cost a build.

slots here is the declaration, not the choice. It says which parts can be dressed. What a particular build chose is opm.slots(key, inputs).

defaultMaterial is optional and often absent — on window_casement only glass and cladding carry one. Where it is missing, the finish is not defaulted but asked for: there is a material-role input (frame_material, sash_material) whose own default decides. Do not read a missing defaultMaterial as "undressed".

inputs — every field the Bauart takes

{
  key:      'width',
  dataType: 'integer',      // integer decimal boolean enum string list object
  unit:     'mm',
  default:  900,

  min: 300,  max: 2000,           // what this Bauart offers
  limitMin: 300, limitMax: 2000,  // what the generator could build at all
  step: 1,

  label:       { default: 'Width', de: 'Breite' },
  description: { default: '…', de: '…' },   // where there is one

  scope: 'type',            // type | generator
  role:  'dimension',       // dimension | material | configuration
  group: 'size',            // one of the groups declared above
  widget: 'slider',         // slider number switch select segmented path

  visibleIf: { bar_type: ['internal', 'applied'] },  // where there is one
  options: [ … ]            // on an enum
}

Every locale is always there. label and description are { default, de, … } — default is English, every other key a locale tag. There is no language negotiation anywhere: all of it arrives, the client picks.

The three axes that are easy to confuse

| | | |---|---| | scope | Who asked for the field. type means the type contract demands it of every vendor in that family. generator means this Bauart chose it. On window_casement only width and height are type; the other thirty are not. | | role | What the field is for. dimension is a measurement, material picks a finish for a slot, configuration is everything else. A count is not a dimension: bars_vertical decides geometry but is not a size. | | group | Where it is rendered. Presentation only, with an order and a translated heading in generator.groups. |

scope and role are different questions and both are needed — group cannot do the job, because a group like frame holds both the frame depth and the finish.

min/max against limitMin/limitMax

The first is what this Bauart offers; the second what the generator could build at all. In a .opm they are usually the same — there is no provider narrowing a range — but both are emitted, because a form and a slider read different ones.

Outside the range is refused, not clamped — but you have to ask for it.

opm.build() does not validate. It takes what it is given and hands it to the program, which is what keeps the one hot call cheap. The rule lives in resolveInputs, and there are two ways to get it:

import { fetchOpm, resolveInputs, OutOfRange } from '@okonomi-gmbh/opm-loader'

const entry = opm.generator('window_casement')
try {
  const inputs = resolveInputs(entry, { width: 99999 })  // throws OutOfRange
  const built = opm.build(entry.key, inputs)
} catch (error) {
  if (error instanceof OutOfRange) { /* the message names the range */ }
}

createClient(...).loadBuild() from the ./client entry does this for you.

resolveInputs also fills in defaults, rounds millimetres to whole numbers and lower-cases enums, so 900 and 900.4 are one model rather than two. It is the same file the generator service uses, which is what stops a page accepting a size its supplier would refuse. Clamping and warning is still the client's job — this only tells you that you must.

options on an enum

{ value: 'oak',                       // an English code; this is stored data
  label: { default: 'Oak', de: 'Eiche' },
  color: '#b08b5a',                   // only where the value names a material
  thumbnail: 'https://…/oak.png' }    // likewise, and absolute

value is a code and is never translated — it goes into the cache key. Only what it is called is translated. color and thumbnail show it and are therefore not translated either; both are absent from an ordinary enum, because there is no colour for DIN links.

visibleIf

Show a field only while another holds one of these values. It hides, it does not remove: a build takes every declared field whether or not the form showed it, because a declaration may not change shape with a value.

list and object

On dataType: 'list', items says what one element is (without key, group and label — an element is not something a form lists), together with minItems/maxItems. On object, fields holds the same shape as inputs, one level down. That is what makes a skirting along a drawn wall run possible; widget: 'path' is the hint that this is not something anybody types.

typeContract is an id, not the document

The file names only 'okonomi/window@1'. What that type demands of every vendor lives in the registry:

https://registry.opm.okonomi.cloud/contracts/okonomi/window@1
https://registry.opm.okonomi.cloud/contracts/okonomi/window@1/source

The first is resolved — what it inherits from core/opening@1 is already folded in. The second is the document as written, which is what somebody writing a contract of their own needs.

What is worth reading there: requires (what every vendor must declare), forbids (what it may not declare — a window forbids depth, because the frame's depth is the product's), frame.datum (where y = 0 sits: a window's own underside, a door's finished floor, a switch combination's centre) and derives.keys (which values the scene fills in rather than asking for).

The whole specification is at registry.opm.okonomi.cloud.

And what a build costs

opm.build(key, inputs)   // geometry, bounds, ports, pivots, clips, warnings
opm.plan(key, inputs)    // the elevation as data — or null if the file carries none
opm.slots(key, inputs)   // this build's slots, each with the finish it was given
opm.output()             // whatever the program printed; for a workbench only
opm.close()              // release the VM, once per file

plan returns null where the file carries no elevation.lua, and that is deliberate: a drawing is how a picker shows what it is offering, and a provider who has not written one should still be able to ship geometry. The host draws nothing rather than refusing the file.

On opm.slots, an undressed slot omits material rather than nulling it — which is what the client-side dressing pass tests for. A file with no materials.json answers with the slots and no finishes at all: geometry still builds, and a picker shows it grey.


The three.js entry, if you want it

./client turns a build into a three.js scene with its animation clips, and can give you the same .glb bytes a generator service would have answered with — so an existing dressing pass keyed on glTF material names keeps working unchanged.

import { createClient } from '@okonomi-gmbh/opm-loader/client'

const client = createClient(opm, { textureBase: '/textures/', via: 'scene' })
const { built, gltf } = await client.loadBuild('window_casement', { width: 900 })
// gltf.scene, gltf.animations — as GLTFLoader hands them over

via: 'scene' builds the three objects directly; via: 'glb' goes through the exporter and back and gives you the bytes as well. It is one assembly, two routes — a second implementation would be a second opinion about where a sash's hinge is.

three is an optional peer dependency. Install it only if you use this entry.


Notes that will save you an afternoon

  • moduleResolution must read exports — bundler, node16 or nodenext. Classic node resolution ignores the map and finds nothing.
  • One VM per file. A LoadedOpm owns its sandbox for its whole life; two providers sharing one would share an _ENV, and the tables in it are writable. Call close() when you are done with a file, not after each build.
  • Building is synchronous and fast. A casement is about 3 ms. There is nothing in flight, so no loading state and no cache is needed for correctness — memoise if you like, not because you must.
  • Refusals are ordinary, and resolveInputs is what raises them. A size outside a Bauart's declared range throws OutOfRange rather than being clamped, and the message names the range. That is the supplier's contract, not a bug. opm.build() on its own does not check — see above.
  • cacheKey is stable, but not across major versions of this package. The key is derived from the canonical inputs and a build version compiled into the loader, so a major release can change it. If you store keys, treat a major bump as an invalidation.

The sandbox

A .opm is somebody else's code, and it is run as such. The VM gets a curated _ENV: math without random, string without the pattern functions, table and a handful of base functions. Absent entirely: io, os, debug, package, require, load, pcall. Chunks are loaded in text mode only — Lua bytecode is an escape route.

Two limits, raised from one hook: a step count (reproducible; the same program is refused the same way on every machine) and a wall clock as a backstop. The pattern functions are withdrawn rather than bounded, because Lua's matcher backtracks inside a single C call where no count hook can reach it.

There is no clock and no random source inside, which is what makes a build deterministic rather than merely usually the same.


Licence

MIT.