Abstract

Data-heavy dashboards — the kind with real-time widgets and dense, constantly-changing information — usually mean picking a frontend framework, setting up a build step, and writing a fair amount of glue code just to keep the DOM in sync with incoming data.

Spacepeech takes a different approach: a dashboard is described entirely as a JSON document — what's on screen, where its data comes from, and what happens on interaction. Spacepeech reads that document and renders it, keeping everything in sync automatically. No compiler, no bundler, no JSX — a page is data, and pure vanilla JavaScript does the rendering.

Because a page is just JSON, it can also be rendered entirely server-side and later hydrated in the browser — useful for SEO, since crawlers see real content on first load instead of an empty <div id="canvas">.

Design Principles

Spacepeech is written as portable, class-based JavaScript — deliberately structured so the same architecture could be ported to Java or C++ without rethinking it. That constraint shapes every decision below.

Single source of truth, no diffing

The JSON tree and the reactive DataStore are the only state that matters. BindingEngine builds a static dependency graph once, at load time — a map from each data path to the exact nodes/properties that reference it. A mutation looks up its dependents directly (O(1)) instead of walking the tree or diffing a Virtual DOM. There's deliberately no Virtual DOM: KISS and YAGNI over a heavier abstraction the project doesn't need.

Total isolation from the DOM and the OS

No core module touches browser globals (window, document, location, requestAnimationFrame) directly — everything is channeled through DomFacade and infra/DomAccess. Plugins are the one exception, and only when they need a browser API with no DomFacade equivalent (StoragePlugin and localStorage, RealtimePlugin and WebSocket) — they're leaf modules, not part of the portable core.

Non-blocking bootstrap

Resolving spacepeech.config.json and the initial backend handshake are both best-effort: a failure in either is reported through ErrorReporter but never stops the app from loading its state and rendering. See Lifecycle.

Clear inputs and outputs

Inputs (sensors): SystemDriver and SystemAdapter feed continuous environmental data ($sys.time, $sys.viewport.*, $sys.online) into the DataStore. Outputs (actions): ActionManager interprets declarative commands (sys.set, sys.copy, sys.toggleFullscreen, sys.navigate, timers, sys.fetch, plugin commands) fired by node events, each step optionally gated by an "if".

Extensible by plugins, not by forking the core

PluginManager is the only sanctioned way to add middlewares, custom actions, custom element types, or external integrations — nothing about a plugin requires touching core files. See Plugins.

Asynchronous module resolution

Remote component dependencies are detected and fetched in parallel before compilation ever starts — rendering never blocks on a network request it didn't already resolve.

Granular, branch-level rendering

The DOM is never redrawn wholesale on a mutation. Changes sync atomically by id using replaceWith, appendChild, or remove — including data-driven collections (select/ul/ol/table generated from data.options/items/matrix/headers+rows).

Configuration

spacepeech.config.json is a static file, served alongside the rest of your assets, optional. It's loaded via Spacepeech.loadConfigFile() when options.configUrl is passed to init(); if it's missing or the fetch fails, startup continues with {} — config resolution fails open by design.

{
  "baseUrl": "https://api.example.com",
  "source": "api",
  "staticPath": "./pages",
  "defaultPage": "main-dashboard",
  "handshakeEndpoint": "/api/handshake",
  "wsEndpoint": "wss://api.example.com/ws"
}
KeyDescription
baseUrlBase for every DataService request (pages, handshake). Falls back to the ?server= query param.
source"api" | "static" | "local" — where pages come from. Falls back to ?source=.
staticPathFile prefix used when source is "static"/"local".
defaultPagePage to load when the URL has no ?page=.
handshakeEndpointIf present, triggers the startup handshake (below). Omit it and no telemetry POST is sent at all.
wsEndpointWebSocket endpoint for RealtimePlugin, used as a fallback if the handshake doesn't return its own.

Final resolution combines, by increasing priority: spacepeech.config.json → the injected __SPACEPEECH_CONFIG__ state → the options object passed directly to init().

Startup handshake & telemetry

If handshakeEndpoint is set, init() POSTs the client's specs (SystemDriver.getClientSpecs() — viewport, user agent, etc.) to it. This is best-effort telemetry: a network failure is reported via ErrorReporter but never blocks the dashboard from loading. Omit handshakeEndpoint entirely and no request is sent at all — it's strictly opt-in.

On success, the full server response is emitted as Events.HANDSHAKE_COMPLETE — any plugin can listen for it to receive backend-negotiated config in the same round trip. There's no fixed response schema beyond one convention: RealtimePlugin looks for a realtime key.

{
  "realtime": {
    "endpoint": "wss://api.example.com/ws/session-abc123",
    "token": "eyJhbGciOi..."
  }
}

The WebSocket endpoint itself resolves in priority order: an explicit options.endpoint > the handshake's realtime.endpoint > wsEndpoint from config > derived from baseUrl (http→ws, https→wss) + options.path (default /ws). With none of the four, RealtimePlugin stays installed but idle.

"website" mode — static HTML hydration for SEO

When a page is served embedded in HTML that's already real and visible, "mode": "website" in spacepeech.config.json switches Renderer into hydration mode instead of the default destructive renderRoot(). This is a bootstrap flag, not a page-JSON key — HydrationPlugin reads it off app._mergedConfig.mode (or an explicit options.mode override at install time) and calls app.renderer.setHydrationMode(true).

  • Matching: for each JSON node, the Renderer looks for an already-painted element with the same id (getHtmlEl()) and reconciles it instead of creating it — no special markup or new contract key needed.
  • Order: child order is preserved via insertBefore, never replaceChildren.
  • Orphan pruning (_pruneOrphans): children omitted → that static subtree is left alone entirely (escape hatch); children: [] → everything static there gets pruned; children: [...] → each is hydrated by id and the rest pruned.
  • Content contract: a convention, not code-enforced — the static HTML should represent the same content its JSON subtree would produce, so a crawler and a hydrated user see the same thing.

Fully opt-in — nothing changes for internal dashboards that don't set it.

Plugin catalog & enabling/disabling

spacepeech-loader.js owns a DEFAULT_PLUGINS array — each entry is { plugin, options?, enabled? }. spacepeechInit() merges it with any customPlugins you pass as its third argument and installs everything whose enabled isn't explicitly false (and whose disabled isn't explicitly true):

const DEFAULT_PLUGINS = [
  { plugin: HydrationPlugin, enabled: true },
  { plugin: UiKitPlugin, enabled: true },
  { plugin: DebugPlugin, options: { logMutations: true, ignoreSystem: true }, enabled: true },
  { plugin: SvgPlugin, enabled: true },
  { plugin: IconPlugin, enabled: true },
  { plugin: InteractiveControlsPlugin, enabled: true },
  { plugin: StoragePlugin, enabled: true },
  { plugin: ObserverPlugin, enabled: true },
  { plugin: FormPlugin, enabled: true },
  { plugin: CalculatorPlugin, enabled: true },
  { plugin: RealtimePlugin, enabled: false } // off by default — turn on once wsEndpoint/handshake is wired up
];

To add your own plugin without touching the library, pass it as the third argument to spacepeechInit:

window.app = await spacepeechInit('canvas', null, [
  { plugin: MyRatingPlugin }
]);

Every default plugin except RealtimePlugin ships enabled — including HydrationPlugin, which is harmless to leave on since it only acts when mode: "website" is actually set. Flip any entry's enabled to false to strip it without forking the loader.

JSON Contract

This is the reference for how a page is described. For plugin-specific actions, see Plugins; for components, see Modules & Components.

Root document version, meta, data, css, components, root

{
  "version": "1.0",
  "meta": { "title": "Tab title", "description": "Optional meta description" },
  "data": {
    "user": { "name": "Nahuel", "role": "Senior Dev" },
    "counter": 0,
    "theme": { "bg": "#0b0f19", "border": "#1e293b" }
  },
  "css": "/* flat global CSS */",
  "components": { /* optional local module catalog — see Modules & Components */ },
  "root": { /* single root node */ }
}

Node structure id, type, class, bind, style, data, actions, children

{
  "id": "unique-string-required",
  "type": "div | span | h1 | p | button | input | textarea | select | ul | ol | table | img | component-instance",
  "class": "css-class {{some.variable}}",
  "active": false,
  "disabled": false,
  "bind": "user.name",
  "style": {
    "background": "{{theme.bg}}",
    "hover": { "transform": "scale(1.05)" },
    "disabled": { "opacity": "0.5" }
  },
  "data": {
    "text": "Hello, {{user.name}}! Clicks: {{counter}} | {{$sys.time}}",
    "value": "Initial value",
    "placeholder": "Type your name..."
  },
  "actions": {
    "click": { "type": "sys.increment", "path": "counter", "delta": 1 }
  },
  "children": []
}

type isn't a closed enum — a plugin can register additional types via app.renderer.registerElementType(type, compilerFn) (e.g. UiKitPlugin adds "toggle-button"). A custom-typed node still gets data/style/bind/actions the standard way — see Plugins.

Data binding & expressions

One-way: {{path.to.value}} inside data.*, style.* (including hover/disabled) or class — dotted paths and hyphenated instance names both work ({{card-1.status}}). A template that is only one expression ("{{path}}") preserves the original type (boolean, number, object); mixing text with an expression always produces a string — this is what lets a checkbox bound to a real boolean stay a boolean instead of becoming the truthy string "false".

Logical expressions: the engine detects operator characters (! = > < & | + - * /) and evaluates the expression instead of a plain path lookup:

{ "data": { "text": "Status: {{power ? 'On' : 'Off'}}" } }
{ "style": { "color": "{{sliderMusic > 80 ? '#f00' : '#0f0'}}" } }
{ "class": "badge {{!auth.username ? 'badge-guest' : 'badge-user'}}" }

Identifiers resolve against the DataStore the same as a simple interpolation; the rest supports ternaries, &&/||/!, comparisons (==, ===, !=, !==, <, >, <=, >=), and basic arithmetic.

Security: ExpressionEvaluator never executes an expression as real JavaScript — there's no Function()/eval() anywhere in it. It tokenizes against an explicit, closed grammar (a character whitelist, not a blacklist) and evaluates the resulting tree by hand, with recursive-descent precedence from ternary down to primary values. The dot is only ever part of an identifier token resolved once against the DataStore — never an operator applied to a live value — and there's no call or property-access rule at all, so there's no sequence of tokens that reaches a real environment object (Function, window, process), even if dataStore.get() ever returned one. Reserved words (true/false/null/undefined/NaN/Infinity) are matched before the identifier ever touches the store.

This is a rewrite: an earlier version did evaluate as real JS, and had exactly the kind of prototype-chain/bracket-notation escape that implies (['constructor']['constructor'](...)-style payloads reaching the real global scope) — the current evaluator exists specifically to close that off, and the regression suite pins the old exploit string as a permanent test case. Treat any other internal doc that still describes eval-based evaluation as stale.

Two-way binding: "bind": "path.to.value" on input, textarea, and select — syncs the control with the data store atomically, preserving focus and cursor position.

System variables $sys.*

VariableTypeDescription
{{$sys.time}}stringLocal time
{{$sys.date}}stringLocal date
{{$sys.timestamp}}numberEpoch ms
{{$sys.viewport.w / .h}}numberViewport size
{{$sys.online}}booleanNetwork status
{{$sys.mouse.x / .y}}numberCursor position

Actions

Any action step — standalone or inside a pipeline — can carry an "if" expression (same grammar as above). If it evaluates falsy, that step is skipped silently and the pipeline continues:

{ "type": "sys.toggleFullscreen", "if": "!$sys.online" }

Core catalog (sys.*):

sys.setsys.togglesys.incrementsys.copysys.toggleFullscreensys.navigatesys.pingNodesys.emitsys.setIntervalsys.clearIntervalsys.timeoutsys.fetch

sys.setInterval takes an optional timerId (re-using an id cancels the previous timer), an intervalMs (default 1000), and the action (or pipeline) to run each tick. sys.clearInterval cancels one by timerId, or every active timer if omitted. sys.timeout takes delayMs (alias intervalMs) and runs once. sys.fetch does a GET and writes the JSON response into the DataStore — either under a single targetPath, or as separate top-level paths per response key if targetPath is omitted.

Plugin actions extend the catalog under their own namespace — never colliding with sys.* or each other. A representative sample (see Plugins for the full per-plugin list):

sys.logstorage.setform.submitcalc.digitws.sendws.reconnectobserver.observeicon.set

Pipelines: a list of action objects runs in declared order, each with its own optional "if":

"actions": {
  "click": [
    { "type": "sys.increment", "path": "metric", "delta": 5 },
    { "type": "sys.set", "path": "status", "value": "Overcharged", "if": "metric > 100" },
    { "type": "sys.copy", "text": "New status: Overcharged" }
  ]
}

Compound elements lists, selects, tables

Instead of writing every <option>/<li>/row by hand, declare the collection in data and let the engine generate the markup:

  • select + data.options: array of { value, label }
  • ul/ol + data.items: array of strings or sub-nodes
  • table + data.matrix: array of arrays
  • table + data.headers + data.rows

These collections don't participate in the automatic {{ }} binding system the way data.text or style.* do — BindingEngine doesn't index arrays under options/items/matrix/rows. Updating one reactively means explicitly emitting Events.UPDATE_NODE_PROP with the matching prop ("data.options", etc.) and the full new array — normalmente ejecutado por un plugin o una acción personalizada.

Modules & Components

Composition isn't a secondary feature bolted onto the contract — it's one of the central pieces of the framework. A page can be built out of reusable components that carry their own isolated state, nest inside one another, and can be reused as either a JSON reference or an inline element.

Local vs. remote

Local components live in the root document's components dictionary. Remote components are anything referenced by a moduleId that isn't in that local catalog — Spacepeech fetches them asynchronously (in parallel, for every missing module at once) via DataService, caches the definition in memory, and instantiates it applying that instance's style/data/actions/children overrides on top.

Declaring a component with local state

Local variables go in components.<moduleId>.data — not in components.<moduleId>.root.data.

{
  "components": {
    "counter-widget": {
      "data": { "clicks": 0 },
      "root": {
        "id": "tpl-counter",
        "type": "button",
        "data": { "text": "Clicks: {{clicks}}" },
        "actions": { "click": { "type": "sys.increment", "path": "clicks", "delta": 1 } },
        "children": [
          { "id": "tpl-badge", "type": "span", "data": { "text": "{{clicks}}" } }
        ]
      }
    }
  }
}

Lifecycle

Bootstrap & first render

  1. Document load: the browser parses index.html, finds the root container, and boots spacepeech-loader.js as an ES module.
  2. Platform isolation: DomAccess/DomFacade are instantiated, caching DOM access references before anything else touches the page.
  3. Config resolution: Spacepeech.init() merges config sources by increasing priority.
  4. Plugin flush: PluginManager.flushPending() runs every queued plugin's install().
  5. Compile & mount: Renderer._compileNode() builds the tree inside an off-DOM DocumentFragment and mounts it atomically.

Architecture

Spacepeech is the orchestrator; it owns every collaborator below and nothing reaches across layers except through the EventBus.

src/core

  • EventBus — on/off/emit. The only channel between modules.
  • DataStore — Prototype-less internal state; emits DATA_CHANGED on every effective write.
  • BindingEngine — O(1) dependency map.
  • ActionManager — Pipelines and execution.

Plugins

A plugin is a plain object: { name: string, install(app, options): Promise<void>|void }.

  • HydrationPlugin: Progressive SSR/static HTML hydration.
  • RealtimePlugin: WebSocket push events.
  • StoragePlugin: Local/Session persistence and JSON export.
  • FormPlugin: Declarative forms and validation state machine.

Downloads

The full source documentation and bundle resources are also available in the dedicated Downloads section: