@hypen-space/core
v0.4.32
Published
Hypen core engine - Platform-agnostic reactive UI runtime
Maintainers
Readme
@hypen-space/core
The reactive runtime for Hypen - a declarative UI language that separates what your UI looks like from how it behaves.
What is Hypen?
Hypen lets you write UI templates in a clean, declarative syntax:
Column {
Text("Hello, ${state.user.name}!")
Button(onClick: @actions.logout) {
Text("Sign Out")
}
}The @hypen-space/core package provides the engine that:
- Parses your Hypen templates
- Tracks reactive state bindings (like
${state.user.name}) - Generates patches when state changes (instead of re-rendering everything)
- Dispatches actions triggered from the UI (like
@actions.logout)
You provide a renderer that applies those patches to your platform (DOM, Canvas, Native, etc).
Installation
npm install @hypen-space/core
# or
bun add @hypen-space/coreCore Concepts
Templates
Hypen templates describe your UI structure. Components can have:
- Arguments:
Text("Hello")orButton(disabled: true) - Children: Nested inside
{ }braces - Applicators: Chained styling like
.padding(16).color(blue)
State Bindings
Use ${state.path} to bind template values to your module's state:
Text("Count: ${state.count}")
Text("User: ${state.user.name}")When state changes, only the affected parts of the UI update.
Actions
Use @actions.name to dispatch events from the UI to your module:
Button(onClick: @actions.increment) { Text("+") }
Button(onClick: @actions.submitForm) { Text("Submit") }Patches
The engine doesn't manipulate the UI directly. Instead, it emits patches - minimal instructions describing what changed:
{ type: "Create", id: "1", elementType: "Text", props: { text: "Hello" } }
{ type: "SetProp", id: "1", name: "text", value: "Hello, World" }
{ type: "Remove", id: "1" }Your renderer applies these patches to the actual platform.
Quick Start
import { Engine, app } from "@hypen-space/core";
// 1. Define your module's state and actions
const counter = app
.defineState({ count: 0 })
.onAction("increment", ({ state }) => state.count++)
.onAction("decrement", ({ state }) => state.count--)
.build();
// 2. Initialize the engine
const engine = new Engine();
await engine.init();
// 3. Connect your renderer
engine.setRenderCallback((patches) => {
myRenderer.applyPatches(patches);
});
// 4. Register the module and render
engine.setModule("counter", counter.actions, counter.stateKeys, counter.initialState);
engine.renderSource(`
Column {
Text("Count: \${state.count}")
Row {
Button(onClick: @actions.decrement) { Text("-") }
Button(onClick: @actions.increment) { Text("+") }
}
}
`);Modules
Modules manage state and handle actions. Use the app builder to define them:
import { app } from "@hypen-space/core";
interface UserState {
user: { id: string; name: string } | null;
loading: boolean;
}
const userModule = app
.defineState<UserState>({ user: null, loading: false })
// Lifecycle handler: receives (state, context?)
.onCreated(async (state, context) => {
state.loading = true;
// State changes are auto-synced via Proxy
})
// Action handler: receives context object { action, state, next, context }
.onAction("loadUser", async ({ state, action, next, context }) => {
const userId = action.payload?.id;
state.user = await fetchUser(userId);
state.loading = false;
// State changes are auto-synced via Proxy
// context.router is available for programmatic navigation
})
.onAction("logout", ({ state }) => {
state.user = null;
})
// Lifecycle handler: receives (state, context?)
.onDestroyed((state, context) => {
console.log("Cleanup");
})
.build();State mutations are automatically tracked via Proxy and synced to the engine.
State
State is automatically tracked via Proxy. Mutations trigger UI updates:
import { createObservableState, batchStateUpdates, getStateSnapshot } from "@hypen-space/core";
const state = createObservableState({ count: 0, items: [] }, {
onChange: (path, oldVal, newVal) => {
console.log(`${path.join(".")} changed: ${oldVal} -> ${newVal}`);
}
});
// Direct mutations are tracked
state.count = 5;
state.items.push("item");
// Batch multiple updates into one render cycle
batchStateUpdates(state, () => {
state.count = 10;
state.items = ["a", "b", "c"];
});
// Get an immutable snapshot
const snapshot = getStateSnapshot(state);Custom Renderers
Extend BaseRenderer to render to any platform:
import { BaseRenderer } from "@hypen-space/core";
class MyRenderer extends BaseRenderer {
protected onCreate(id: string, type: string, props: Record<string, any>) {
// Create an element
}
protected onSetProp(id: string, name: string, value: any) {
// Update a property
}
protected onSetText(id: string, text: string) {
// Set text content
}
protected onInsert(parentId: string, id: string, beforeId?: string) {
// Insert into parent
}
protected onMove(parentId: string, id: string, beforeId?: string) {
// Reorder element
}
protected onRemove(id: string) {
// Remove element
}
}See @hypen-space/web for a DOM renderer implementation.
Routing
Built-in hash or pathname routing:
import { HypenRouter } from "@hypen-space/core";
const router = new HypenRouter();
router.navigate("/products/123");
router.subscribe((state) => {
console.log("Path:", state.currentPath);
console.log("Params:", state.params); // { id: "123" }
console.log("Query:", state.query);
});Use built-in components in templates:
Router {
Route(path: "/") { HomePage }
Route(path: "/products/:id") { ProductPage }
}
Link(to: "/products/42") { Text("View Product") }Component Discovery
For larger apps, organize components as files and auto-discover them:
import { discoverComponents, loadDiscoveredComponents } from "@hypen-space/core";
const components = await discoverComponents("./src/components");
const loaded = await loadDiscoveredComponents(components);Supported file patterns:
Counter/
├── component.hypen # Template
└── component.ts # Module
Counter.hypen # Or sibling files
Counter.ts
Counter/
├── index.hypen # Or index-based
└── index.tsWatch for changes (hot reload):
import { watchComponents } from "@hypen-space/core";
const watcher = watchComponents("./src/components", {
onUpdate: (c) => console.log("Updated:", c.name),
});Component Loader
Register components programmatically:
import { componentLoader, ComponentLoader } from "@hypen-space/core";
// Global loader
componentLoader.register("Counter", counterModule, counterTemplate);
componentLoader.get("Counter");
componentLoader.has("Counter");
// Or create your own
const loader = new ComponentLoader();
await loader.loadFromComponentsDir("./src/components");Remote UI
Connect to a Hypen server for server-driven UI:
import { RemoteEngine } from "@hypen-space/core";
const remote = new RemoteEngine("ws://localhost:3000", {
autoReconnect: true,
});
remote
.onPatches((patches) => renderer.applyPatches(patches))
.onStateUpdate((state) => console.log("Server state:", state))
.onConnect(() => console.log("Connected"));
await remote.connect();
remote.dispatchAction("loadData", { page: 1 });Global Context
Share state and events across modules:
import { HypenGlobalContext } from "@hypen-space/core";
const context = new HypenGlobalContext();
context.registerModule("auth", authModule);
context.registerModule("cart", cartModule);
// Cross-module access
const user = context.getModule("auth").getState().user;
// Event bus
context.on("userLoggedIn", (user) => { /* ... */ });
context.emit("userLoggedIn", { id: "1", name: "Ian" });Browser vs Node.js
// Node.js / Bundler - WASM loads automatically
import { Engine } from "@hypen-space/core";
const engine = new Engine();
await engine.init();
// Browser - specify WASM path
import { BrowserEngine } from "@hypen-space/core";
const engine = new BrowserEngine();
await engine.init({ wasmPath: "/hypen_engine_bg.wasm" });Single-File Components
Use template literals with the hypen, state, item, and index helpers:
import { app, hypen, state, item, index } from "@hypen-space/core";
export default app
.defineState({ items: ["A", "B", "C"] })
.ui(hypen`
Column {
ForEach(items: ${state.items}) {
Text("${index}: ${item}")
}
}
`);Error Handling
Result Type
Type-safe error handling without exceptions:
import { Ok, Err, fromPromise, match, all, isOk, isErr } from "@hypen-space/core";
// Create results
const success = Ok(42);
const failure = Err(new Error("failed"));
// Pattern matching
const message = match(result, {
ok: (value) => `Got ${value}`,
err: (error) => `Failed: ${error.message}`,
});
// Convert promises to Result
const result = await fromPromise(fetch("/api/data"));
// Combine multiple results
const combined = all([result1, result2, result3]);Module Error Handler
Handle errors in module actions:
app
.defineState({ error: null })
.onError(({ error, action, state }) => {
console.error(`Action ${action.name} failed:`, error);
state.error = error.message;
return { handled: true }; // Prevent error from propagating
})
.onAction("riskyAction", async ({ state }) => {
throw new Error("Something went wrong");
});Session Lifecycle
Handle connection state for remote UI:
app
.defineState({ connected: true })
.onDisconnect(({ state, reason }) => {
state.connected = false;
console.log("Disconnected:", reason);
})
.onReconnect(({ state, wasExpired }) => {
state.connected = true;
if (wasExpired) {
// Session was restored from server
}
})
.onExpire(({ state, reason }) => {
// Session expired, need to re-authenticate
state.connected = false;
});Disposable Pattern
Resource management with automatic cleanup:
import { DisposableStack, using, disposableListener } from "@hypen-space/core";
// Automatic cleanup with using()
await using(new DisposableStack(), async (stack) => {
const listener = stack.use(
disposableListener(window, "resize", handleResize)
);
// listener is automatically removed when scope exits
});
// Manual stack management
const stack = new DisposableStack();
stack.use(disposableTimeout(() => {}, 1000));
stack.use(disposableInterval(() => {}, 100));
stack.dispose(); // Cleans up everythingRetry Utilities
Retry failed operations with backoff:
import { retry, retryResult, RetryPresets } from "@hypen-space/core";
// Simple retry with exponential backoff
const data = await retry(
() => fetch("/api/data"),
{ maxAttempts: 3, backoff: "exponential" }
);
// Retry returning Result type
const result = await retryResult(
() => fetchData(),
RetryPresets.network // Pre-configured for network errors
);Logger
Structured logging with levels:
import { createLogger, setLogLevel, Logger } from "@hypen-space/core";
const log = createLogger("MyModule");
log.debug("Detailed info");
log.info("Normal operation");
log.warn("Something unexpected");
log.error("Something failed", error);
// Configure globally
setLogLevel("warn"); // Only warn and errorTyped Events
Type-safe event emitter:
import { TypedEventEmitter, createEventEmitter } from "@hypen-space/core";
interface MyEvents {
userLogin: { userId: string };
dataLoaded: { items: string[] };
}
const events = createEventEmitter<MyEvents>();
events.on("userLogin", ({ userId }) => {
console.log("User logged in:", userId);
});
events.emit("userLogin", { userId: "123" });Package Exports
| Export | Description |
|--------|-------------|
| @hypen-space/core | Main entry point |
| @hypen-space/core/engine | Low-level WASM engine |
| @hypen-space/core/engine/browser | Browser-optimized engine |
| @hypen-space/core/app | Module builder |
| @hypen-space/core/state | Observable state utilities |
| @hypen-space/core/renderer | Abstract renderer |
| @hypen-space/core/router | Routing system |
| @hypen-space/core/context | Global context |
| @hypen-space/core/remote | Remote UI protocol |
| @hypen-space/core/loader | Component loader |
| @hypen-space/core/discovery | Component discovery |
| @hypen-space/core/components | Built-in Router, Route, Link |
| @hypen-space/core/result | Result type for error handling |
| @hypen-space/core/disposable | Disposable pattern utilities |
| @hypen-space/core/retry | Retry with backoff |
| @hypen-space/core/logger | Structured logging |
| @hypen-space/core/events | Typed event emitter |
| @hypen-space/core/hypen | Template helpers (hypen, state, item, index) |
Requirements
- Node.js >= 18.0.0
- TypeScript 5+ (optional)
License
MIT
