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

@spearwolf/shadow-objects

v0.35.0

Published

a reactive entity-component framework that feels at home in the shadows

Readme

Shadow Objects

npm

a reactive entity-component framework that feels at home in the shadows

Shadow Objects is an Entity Component System (ECS) for the browser platform. It separates application logic from its presentation, and not just logically: the logic runs in a Shadow Environment, which lives either on the main thread (LocalShadowObjectEnv) or inside a web worker (RemoteWorkerEnv). Your Shadow Object code is identical in both cases. Only the proxy gets swapped.

Entities are lightweight nodes in a tree. Shadow Objects are ECS components that attach behavior to them. The View is authoritative for structure, not for behavior: it decides which entities exist and which properties they carry, while the Registry decides which Shadow Objects land on them.

Installation

npm install @spearwolf/shadow-objects

Exactly one copy of @spearwolf/signalize and one of @spearwolf/eventize may stand in the dependency tree. Both key their marker slots with realm-wide symbols, so two majors of either share one slot per object and fail at the boundary between them. You do not have to install signalize for this: the reactivity primitives reach a Shadow Object as arguments — createSignal, createEffect and createMemo come in through the creation API, and nothing here asks you to import them yourself. eventize is the other way round: its surface is reached through that package's free functions — on, once, off, emit — imported from it directly, so code that imports those functions, in the view or in a Shadow Object, needs eventize in your own manifest, at the range this package declares. Whoever does put signalize next to this package takes the range this package declares, ^1.0.0. The latest tag of signalize sits inside that range today, so a plain npm install @spearwolf/signalize currently lands on the one copy — and stops doing so the day a signalize 2.0 takes the tag over, without anything in your manifest changing. npm ls @spearwolf/signalize — or pnpm why @spearwolf/signalize — says whether it stayed at one.

Quick Example

<!-- index.html -- the view layer -->
<script type="module">
  import '@spearwolf/shadow-objects/elements.js';
</script>

<shae-worker src="./my-logic.js"></shae-worker>

<shae-ent token="my-component">
  <shae-prop name="step" value="1" type="int"></shae-prop>
</shae-ent>
// my-logic.js -- runs in the shadow environment
// A shadow object is an ECS component: the body is the setup phase, after that it only reacts.
// It runs once, for as long as this shadow object stays on the entity.
function MyComponent({useProperty, createSignal, onViewEvent, dispatchMessageToView}) {
  const step = useProperty('step');
  const count = createSignal(0);

  onViewEvent((type) => {
    if (type === 'increment') {
      count.set(count.value + (step() ?? 1));
      dispatchMessageToView('count-changed', {value: count.value});
    }
  });
}

// The module exports the registry (component manifest) under the name `shadowObjects` --
// the loader reads exactly that named export. A view node with the token 'my-component'
// gets this shadow object.
export const shadowObjects = {
  define: {
    'my-component': MyComponent,
  },
};

Need to register a shadow object at runtime instead? @spearwolf/shadow-objects/shadow-objects.js exports a helper object of the same name for that, with a define(token, constructor) method. It is a separate thing from the registry a module exports: the helper writes into a Registry, the export declares one.

@spearwolf/shadow-objects/FrameLoop.js is the same kind of subpath: it carries the FrameLoop class without the view layer, for code that runs inside a worker.

Element Lifecycle

The custom elements clean up after themselves — no teardown call for you to make, because a framework re-rendering a subtree would not make one either.

All three elements take their subscriptions up when they first connect, not when they are built — an element created with document.createElement() and never put into a document holds no effect and no event subscription, so nothing on the module level points at it and it can be collected. A <shae-worker> does own its ShadowEnv from the moment it is built; what it does not own is anything that listens.

<shae-ent> and <shae-prop> release their subscriptions again one microtask after they leave the document, which makes them collectable once more. A move within a single task never reaches that point, so a re-render costs nothing. And the release is reversible: an element put back into the document takes its subscriptions up again and carries the same ViewComponent and the same uuid it left with. destroy() does it by hand, isDestroyed reads the current state.

What a released element is written in the meantime is where the two part company. <shae-ent> keeps it — token, ns and forward-custom-events stand in the signals and are written out to the attributes as the element reconnects. <shae-prop> re-reads its attributes and looks its host up again on every connect, released or not, so a prop.entNode written in that window is replaced rather than applied — and so is a prop.value where the value attribute carries something. Where it carries nothing, the property write stands.

<shae-worker> uses the same two names for something stronger. Its teardown takes the Shadow Environment with it, and an environment cannot be rebuilt — a released <shae-worker> stays released, and a new one is the way back. See the API Reference for all three in detail.

The Five Domains

| # | Domain | Responsibility | Where it lives | |---|---|---|---| | 1 | View | Structure, properties, input | always the main thread | | 2 | Environment | Place of execution, transport | main thread or worker | | 3 | Kernel | Lifecycle, entity tree | inside the environment | | 4 | Composition | Registry, token, routing | inside the environment | | 5 | Shadow Object | Application logic, reactivity, communication | inside the environment |

Each domain, what it owns, what it must not touch, and the invariants that hold the whole thing together are written up in the project README and in Concepts.

Every environment can be asked what it holds: ShadowEnv.get(ns).inspect() answers with a JSON-safe snapshot of the component tree and the Entity Tree behind it, Shadow Objects and Entity Contexts included -- see Inspecting an Environment.

Security

The src of a <shae-worker> is a module URL, resolved against the document and run with a dynamic import(); the loaded module acts as the application's origin. Set it only from values the application trusts, and constrain it in production with a Content Security Policy delivered on every response of the origin — a policy scoped to only the document's response, or set through <meta>, never reaches a worker script loaded from a network URL.

Content-Security-Policy: script-src 'self'; worker-src 'self' blob:

Full detail — why worker-src needs blob: for the @spearwolf/shadow-objects/bundle.js entry point, and which response has to carry the header for every other one — is in the API Reference.

exposeShadowEnvsToModelContext() from @spearwolf/shadow-objects/model-context.js is a second way state leaves the page: it hands every Shadow Environment to an AI agent through the browser's model context (WebMCP), read-only, and every value in every answer is application state. Nothing is exposed without a decision; keep the call behind a development switch and name the properties to redact. The declarative form, <shae-worker expose-to-model-context>, exposes that element's environment alone as a share of the same registration, and belongs in development markup for the same reason the call belongs behind a switch. Details under Exposing Environments to an Agent.

Testing

@spearwolf/shadow-objects/testing.js runs Shadow Objects on the real Kernel in a unit test -- no DOM, no worker, no View Layer, and no hand-built stand-in for the creation API. mountShadowObject(constructa, {props, contexts}) is the single-object case: it hands back the instance, the properties and contexts it reads, the messages it sent towards the View, and a settle() that waits until the framework has finished reacting. createTestKernel() is the layer under it, for a test that needs several Shadow Objects on one Entity, a parent, or a route. Reports the Kernel swallows through runGuarded() -- a throwing onDestroy among them -- are recorded and fail the teardown instead of passing unseen. The subpath imports no test runner and ships in no application bundle. Details under Testing Shadow Objects.

Documentation