Skip to content

A lightweight dependency injection and component management framework for TypeScript web applications.

License

Notifications You must be signed in to change notification settings

mikeseghers/hydra-framework

Repository files navigation

Hydra

A lightweight dependency injection and component management framework for TypeScript web applications.

What is Hydra?

Hydra helps you build structured, maintainable client-side applications without heavy frameworks. It provides:

  • Dependency Injection - Wire up services and components declaratively
  • Component Lifecycle - Async initialization with the Loadable interface
  • Event System - Type-safe events with RxJS integration
  • DOM Binding - Two approaches: CSS selectors or data attributes
  • Zero Build Required - Works with vanilla HTML + TypeScript/JavaScript

Hydra is ideal for static websites, server-rendered pages, or any project where you want structure without a full SPA framework.

Installation

npm install @mikeseghers/hydra rxjs

Quick Start

1. Create your HTML

<!DOCTYPE html>
<html>
<head>
  <title>My Hydra App</title>
</head>
<body>
  <div id="app">
    <h1 id="greeting">Hello</h1>
    <button id="change-btn">Change Greeting</button>
  </div>

  <script type="module" src="./main.ts"></script>
</body>
</html>

2. Define a Service

Services are singletons that provide shared functionality.

// services/GreetingService.ts
export class GreetingService {
  private greetings = ['Hello', 'Bonjour', 'Hola', 'Ciao'];
  private index = 0;

  getNextGreeting(): string {
    this.index = (this.index + 1) % this.greetings.length;
    return this.greetings[this.index];
  }
}

3. Define a PageController

PageControllers are top-level controllers that coordinate your page.

// pages/HomePage.ts
import type { PageController } from '@mikeseghers/hydra';
import { GreetingService } from '../services/GreetingService';

interface Elements {
  greeting: HTMLHeadingElement;
  changeButton: HTMLButtonElement;
}

export class HomePage implements PageController {
  constructor(
    private greetingService: GreetingService,
    private elements: Elements
  ) {}

  load(): void {
    this.elements.changeButton.addEventListener('click', () => {
      this.elements.greeting.textContent = this.greetingService.getNextGreeting();
    });
  }
}

4. Create a Context

Contexts organize your registrations and declare dependencies.

// contexts/AppContext.ts
import { Hydra, HydraContext, service, htmlElementDescriptor } from '@mikeseghers/hydra';
import { GreetingService } from '../services/GreetingService';
import { HomePage } from '../pages/HomePage';

const homePageElements = {
  greeting: htmlElementDescriptor('#greeting', HTMLHeadingElement),
  changeButton: htmlElementDescriptor('#change-btn', HTMLButtonElement)
};

export const AppContext: HydraContext = {
  register(hydra: Hydra): void {
    hydra.registerService(GreetingService);
    hydra.registerPageController(HomePage, [
      service(GreetingService),
      homePageElements
    ]);
  }
};

5. Bootstrap

// main.ts
import { Hydra } from '@mikeseghers/hydra';
import { AppContext } from './contexts/AppContext';

Hydra.registerContext(AppContext);
Hydra.getInstance(); // Hooks into window.onload and boots automatically

That's it! When the page loads, Hydra will:

  1. Instantiate all services
  2. Resolve DOM elements
  3. Construct PageControllers with their dependencies
  4. Call load() on everything

Core Concepts

Services

Services are singletons for shared logic (API clients, state management, utilities).

class ApiService {
  async get<T>(url: string): Promise<T> {
    const response = await fetch(url);
    return response.json();
  }
}

class UserService {
  constructor(private api: ApiService) {}

  getUser(id: string) {
    return this.api.get(`/users/${id}`);
  }
}

// Registration with dependencies
hydra.registerService(ApiService);
hydra.registerService(UserService, [service(ApiService)]);

Mediators

Mediators are event-capable components for cross-cutting concerns (notifications, modals, state).

import { AbstractMediator } from '@mikeseghers/hydra';

interface NotificationEvents {
  shown: { message: string };
  hidden: { message: string };
}

interface Elements {
  container: HTMLDivElement;
}

class NotificationMediator extends AbstractMediator<NotificationEvents> {
  constructor(private elements: Elements) {
    super();
  }

  load(): void {
    // Ready to show notifications
  }

  show(message: string): void {
    const el = document.createElement('div');
    el.textContent = message;
    this.elements.container.appendChild(el);
    this.emit('shown', { message });

    setTimeout(() => {
      el.remove();
      this.emit('hidden', { message });
    }, 3000);
  }
}

Listening to events:

// With callbacks
notifications.addListener('shown', (payload) => {
  console.log('Notification shown:', payload.message);
});

// With RxJS
notifications.fromMediatorEvent('shown').subscribe((payload) => {
  console.log('Notification shown:', payload.message);
});

PageControllers

PageControllers are top-level page controllers. They receive dependencies and coordinate the page.

class DashboardPage implements PageController {
  constructor(
    private userService: UserService,
    private notifications: NotificationMediator,
    private elements: DashboardElements
  ) {}

  async load(): Promise<void> {
    const user = await this.userService.getCurrentUser();
    this.elements.userName.textContent = user.name;
    this.notifications.show(`Welcome back, ${user.name}!`);
  }
}

hydra.registerPageController(DashboardPage, [
  service(UserService),
  mediator(NotificationMediator),
  dashboardElements
]);

Components

Components are reusable UI elements with their own DOM structure.

import { AbstractComponent, htmlElementDescriptor, constructComponent } from '@mikeseghers/hydra';

const cardElements = {
  title: htmlElementDescriptor('.card-title', HTMLHeadingElement),
  content: htmlElementDescriptor('.card-content', HTMLDivElement)
};

class Card extends AbstractComponent<HTMLDivElement, typeof cardElements> {
  setTitle(title: string): void {
    this.elements.title.textContent = title;
  }

  setContent(content: string): void {
    this.elements.content.textContent = content;
  }

  destroy(): void {
    this.rootElement.remove();
  }
}

// Create from existing DOM element
const card = constructComponent(Card, '.card', HTMLDivElement, cardElements);

// Or from a template
const card = constructComponent(Card, '#card-template', HTMLDivElement, cardElements);

Element Binding

Hydra uses element schemas to define what DOM elements a Mediator needs. The schema is defined once in a separate file and provides both runtime validation and TypeScript types.

Element Schema Basics

// elements/MyElements.ts
import { elements, selector, collection, selectorAll } from '@mikeseghers/hydra';

export const MyElements = elements({
  // Data attribute discovery (matches data-hydra-element="propertyName")
  indicator: HTMLSpanElement,
  statusText: HTMLSpanElement,

  // CSS selector discovery
  submitBtn: selector('#submit-btn', HTMLButtonElement),
  emailInput: selector('input[type="email"]', HTMLInputElement),

  // Collections
  items: collection(HTMLLIElement),           // via data attributes
  checkboxes: selectorAll('.checkbox', HTMLInputElement)  // via CSS selector
});

Data Attribute Discovery

Use the HTMLElement type directly. Hydra matches property names to data-hydra-element values:

// elements/StatusElements.ts
import { elements } from '@mikeseghers/hydra';

export const StatusElements = elements({
  indicator: HTMLSpanElement,   // ← matches data-hydra-element="indicator"
  statusText: HTMLSpanElement   // ← matches data-hydra-element="statusText"
});

HTML:

<div data-hydra-mediator="StatusMediator">
  <span data-hydra-element="indicator"></span>
  <span data-hydra-element="statusText">Ready</span>
</div>

CSS Selector Discovery

Use selector() or selectorAll() to find elements via CSS selectors:

// elements/NotificationElements.ts
import { elements, selector } from '@mikeseghers/hydra';

export const NotificationElements = elements({
  container: selector('#notifications', HTMLDivElement)
});

HTML:

<div id="notifications"></div>

Mixed Schemas

You can mix both approaches in a single schema:

export const FormElements = elements({
  // CSS selectors for structural elements
  form: selector('form.contact', HTMLFormElement),
  submitBtn: selector('#submit', HTMLButtonElement),

  // Data attributes for dynamic elements
  errorMessage: HTMLSpanElement,
  successMessage: HTMLSpanElement
});

Using Schemas in Mediators

import { AbstractMediator, ElementsOf } from '@mikeseghers/hydra';
import { StatusElements } from '../elements/StatusElements';

// Type is derived from the schema
type Elements = ElementsOf<typeof StatusElements>;

class StatusMediator extends AbstractMediator<StatusEvents> {
  #elements: Elements;

  constructor(elements: Elements) {
    super();
    this.#elements = elements; // Already validated by Hydra!
  }
}

Registration

// contexts/AppContext.ts
import { StatusElements } from '../elements/StatusElements';
import { NotificationElements } from '../elements/NotificationElements';

hydra.registerMediator(StatusMediator, [StatusElements]);
hydra.registerMediator(NotificationMediator, [NotificationElements]);

Schema Entry Reference

Entry Type Syntax Discovery Method
Single element HTMLButtonElement data-hydra-element="propName"
Collection collection(HTMLLIElement) Multiple data-hydra-element="propName"
Single via selector selector('#id', HTMLDivElement) CSS selector
Collection via selector selectorAll('.class', HTMLInputElement) CSS selector (all matches)

Data Attributes Reference

Attribute Purpose Example
data-hydra-mediator Marks Mediator root, value is class name data-hydra-mediator="StatusMediator"
data-hydra-element Marks element, value must match schema property name data-hydra-element="indicator"
data-hydra-qualifier For multiple instances of same Mediator data-hydra-qualifier="sidebar"

When to Use Each Discovery Method

Scenario Recommended
Existing HTML you can't modify selector() / selectorAll()
New project, self-documenting HTML Direct types (data attributes)
Complex selectors (:nth-child, etc.) selector() / selectorAll()
Multiple Mediator instances with qualifiers Direct types (data attributes)

Dependency Injection

Dependency Types

import { service, mediator, value, htmlElementDescriptor } from '@mikeseghers/hydra';

hydra.registerPageController(MyPage, [
  service(ApiService),           // Inject a service
  mediator(NotificationMediator),    // Inject a Mediator
  value('https://api.example.com'), // Inject a constant
  myPageElements                 // Inject DOM elements
]);

Services with Dependencies

class CacheService {
  private cache = new Map<string, any>();

  get<T>(key: string): T | undefined {
    return this.cache.get(key);
  }

  set<T>(key: string, value: T): void {
    this.cache.set(key, value);
  }
}

class ApiService {
  constructor(
    private cache: CacheService,
    private baseUrl: string
  ) {}

  async get<T>(endpoint: string): Promise<T> {
    const cached = this.cache.get<T>(endpoint);
    if (cached) return cached;

    const data = await fetch(`${this.baseUrl}${endpoint}`).then(r => r.json());
    this.cache.set(endpoint, data);
    return data;
  }
}

hydra.registerService(CacheService);
hydra.registerService(ApiService, [
  service(CacheService),
  value('https://api.example.com')
]);

Qualified Mediators

Create multiple instances of the same Mediator:

// Registration
hydra.registerMediator(FormMediator, [FormElements]);

// Usage in PageController
hydra.registerPageController(SettingsPage, [
  mediator(FormMediator, { qualifier: 'profile' }),
  mediator(FormMediator, { qualifier: 'password' })
]);
<div data-hydra-mediator="FormMediator" data-hydra-qualifier="profile">
  <input data-hydra-element="input" type="text" />
</div>

<div data-hydra-mediator="FormMediator" data-hydra-qualifier="password">
  <input data-hydra-element="input" type="password" />
</div>

Organizing Your Code

Recommended Project Structure

src/
├── services/           # Business logic, API clients
│   ├── ApiService.ts
│   └── UserService.ts
├── mediators/          # Event-capable components
│   ├── NotificationMediator.ts
│   └── ModalMediator.ts
├── elements/           # Element schemas
│   ├── StatusElements.ts
│   └── FormElements.ts
├── components/         # Reusable UI components
│   ├── CardComponent.ts
│   └── ListComponent.ts
├── pages/              # PageController controllers
│   ├── HomePage.ts
│   └── SettingsPage.ts
├── contexts/           # Hydra registrations
│   └── AppContext.ts
└── main.ts             # Bootstrap

Multiple Contexts

Split registrations by feature:

// contexts/AuthContext.ts
export const AuthContext: HydraContext = {
  register(hydra: Hydra): void {
    hydra.registerService(AuthService);
    hydra.registerService(SessionService, [service(AuthService)]);
    hydra.registerMediator(LoginFormMediator, [LoginFormElements]);
  }
};

// contexts/DashboardContext.ts
export const DashboardContext: HydraContext = {
  register(hydra: Hydra): void {
    hydra.registerService(AnalyticsService);
    hydra.registerMediator(ChartMediator, [chartElements]);
  }
};

// main.ts
Hydra.registerContext(AuthContext);
Hydra.registerContext(DashboardContext);
Hydra.getInstance();

Working with Templates

Clone HTML templates for dynamic content:

<template id="card-template">
  <div class="card">
    <h3 class="card-title"></h3>
    <p class="card-content"></p>
  </div>
</template>

<div id="card-container"></div>
import { cloneTemplateContent, htmlElementDescriptor } from '@mikeseghers/hydra';

const cardElements = {
  title: htmlElementDescriptor('.card-title', HTMLHeadingElement),
  content: htmlElementDescriptor('.card-content', HTMLParagraphElement)
};

function createCard(title: string, content: string): HTMLElement {
  const { rootElement, templateElements } = cloneTemplateContent(
    'card-template',
    HTMLDivElement,
    cardElements
  );

  templateElements.title.textContent = title;
  templateElements.content.textContent = content;

  document.getElementById('card-container')!.appendChild(rootElement);
  return rootElement;
}

API Reference

Hydra Class

// Static methods
Hydra.getInstance(): Hydra
Hydra.registerContext(context: HydraContext): void

// Instance methods
hydra.registerService(constructor, dependencies?): void
hydra.registerMediator(constructor, dependencies): void
hydra.registerPageController(constructor, dependencies): void
hydra.registerServiceInstance(instance, name?): HydraRegistry
hydra.registerComponentInstance(instance, name?): HydraRegistry
hydra.getServiceInstance(type, name?): T
hydra.getComponentInstance(type, name?): T
hydra.getMediatorInstance(definition): Loadable

Dependency Functions

service(ServiceType)                    // Inject a service
mediator(MediatorType, options?)        // Inject a Mediator
value(v)                                // Inject a constant

Element Schema Functions

elements(schema)                        // Create element schema
selector(cssSelector, Type)             // Single element via CSS selector
selectorAll(cssSelector, Type)          // Collection via CSS selector
collection(Type)                        // Collection via data attributes

Element Schema Types

ElementsOf<Schema>                      // Extract elements type from schema
ElementSchema<D>                        // Schema type
ElementCollection<T>                    // Collection marker (data attributes)
ElementWithSelector<T>                  // Single element with selector
ElementCollectionWithSelector<T>        // Collection with selector

Legacy Element Descriptors

htmlElementDescriptor(selector, type)   // Single element descriptor
htmlElementCollectionDescriptor(selector, type)  // Collection descriptor

Type Assertion Utilities

assertElementType(element, Type, context?)   // Validate single element
assertElementTypes(elements, Type, context?) // Validate collection

Discovery Utilities

discoverMediators(root?): DiscoveredMediator[]
findMediator(discovered, name, qualifier?): DiscoveredMediator | undefined

Example Project

See examples/notes-app/ for a complete working example demonstrating:

  • Services (NoteService)
  • Mediators with events (NotificationMediator, AppStateMediator, StatusMediator)
  • Components with subcomponents (NoteListComponent, NoteEditorComponent)
  • Both element binding approaches (traditional + data attributes)
  • Template cloning for dynamic lists

Run it:

cd examples/notes-app
npm install
npm run dev

Browser Support

  • ES6+ JavaScript
  • Modern browsers (Chrome, Firefox, Safari, Edge)

Dependencies

  • RxJS (^7.0.0) - For reactive event handling in Mediators

License

MIT

Contributing

Contributions welcome! Please open an issue or submit a pull request.

About

A lightweight dependency injection and component management framework for TypeScript web applications.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published