Okalit
v0.1.7

Okalit Framework

A progressive Web Components framework built on Lit, with signal-based reactivity via uhtml, modern TC39 decorators, and enterprise-grade modular architecture.

< 3KB
Core overhead (gzip)
0ms
Virtual DOM diffing
Native
Web Components standard

Key Principles

  • Signals over VDOM — Fine-grained reactivity via uhtml signals. Updates target specific DOM nodes, not entire trees.
  • Standards-first — Built on native Custom Elements (Lit). Works in any HTML page, alongside any framework.
  • TC39 Decorators — Clean, declarative component definitions using Stage-3 decorators.
  • Enterprise Architecture — Opinionated App → Module → Page hierarchy with dependency injection, routing, and global state channels.

Installation

CLI (Recommended)

Terminal
npx @okalit/cli new my-app
cd my-app
npm install
npm run dev

Manual Setup

Terminal
npm install @okalit/core lit uhtml

Okalit requires a build tool that supports TC39 decorators (Stage 3). Recommended: rsbuild, vite, or esbuild with the decorators plugin.

CLI

The Okalit CLI scaffolds projects and generates components, modules, pages, services, and guards following the framework's conventions.

Install globally (optional)
npm install -g @okalit/cli

Create a New Project

Terminal
okalit new my-app

Or with npx (no global install):

Terminal
npx @okalit/cli new my-app

The CLI will prompt you interactively. It copies the template, renames the project in package.json and index.html, and outputs the next steps.

Interactive output
user@local:~$ okalit new my-app

[✓] Empty Project: No

🚀 Creating project "my-app" using structure-example...

✨ Okalit project generated successfully!

Next steps:
  cd my-app
  npm install
  npm run dev

Generate Command

Use okalit -g (or okalit --generate) followed by a type flag and the target path to scaffold files.

FlagAliasWhat it generates
-c--componentComponent (JS + stylesheet)
-p--pagePage with PageMixin (JS + stylesheet)
-s--serviceREST service (OkalitService)
--gqserviceGraphQL service (OkalitGraphqlService)
-m--moduleFull module (module + routes + default page)
--guardNavigation guard function

Generate a Component

Terminal
okalit -g -c ./src/components/atoms/user-card

Creates:

Generated files
src/components/atoms/user-card/
├── user-card.js       # Component class extending Okalit
└── user-card.scss     # Empty stylesheet (scss or css auto-detected)

Generated code:

user-card.js
import { Okalit, defineElement, html } from "@okalit/core";

import styles from "./user-card.scss?inline";
import global from "@styles/global.scss?inline";

@defineElement({
  tag: "user-card",
  styles: [styles, global]
})
export class UserCard extends Okalit {
  render() {
    return html`
      <div class="user-card">
        <slot></slot>
      </div>
    `;
  }
}

Generate a Page

Terminal
okalit -g -p ./src/modules/home/pages/settings

Creates:

Generated files
src/modules/home/pages/settings/
├── settings.js        # Page class with PageMixin
└── settings.scss      # Empty stylesheet

Generated code:

settings.js
import { Okalit, defineElement, html, PageMixin } from "@okalit/core";

import styles from "./settings.scss?inline";
import global from "@styles/global.scss?inline";

@defineElement({
  tag: "settings",
  styles: [styles, global]
})
export class Settings extends PageMixin(Okalit) {
  render() {
    return html`
      <div class="settings">
        <slot></slot>
      </div>
    `;
  }
}

Generate a Module

Modules are the most powerful generator — they create a full feature boundary with routing.

Terminal
okalit -g -m ./src/modules/billing

Creates:

Generated structure
src/modules/billing/
├── billing.module.js    # ModuleMixin entry point
├── billing.routes.js    # Module route definitions
└── pages/
    ├── billing.page.js  # Default page with PageMixin
    └── billing.page.scss

Generated files:

billing.module.js
import { ModuleMixin, Okalit, defineElement } from "@okalit/core";

@defineElement({ tag: "billing-module" })
export class BillingModule extends ModuleMixin(Okalit) {}
billing.routes.js
export default [
  {
    path: "",
    component: "billing-page",
    import: () => import("./pages/billing.page.js")
  }
];
pages/billing.page.js
import { Okalit, defineElement, html, PageMixin, t } from "@okalit/core";

import styles from "./billing.page.scss?inline";
import global from "@styles/global.scss?inline";

@defineElement({
  tag: "billing-page",
  styles: [styles, global]
})
export class BillingPage extends PageMixin(Okalit) {
  render() {
    return html`
      <main>
        <h1>${t("WELCOME")}</h1>
      </main>
    `;
  }
}

Auto-wiring: When generating a module, the CLI automatically imports and registers it in your src/app.routes.js file.

Generate a REST Service

Terminal
okalit -g -s ./src/services/posts

Creates src/services/posts.service.js:

posts.service.js
import { OkalitService, service } from "@okalit/core";

@service("postsService")
export class PostsService extends OkalitService {
  constructor() {
    super();
    this.configure({
      baseUrl: '',
      cache: true,
      cacheTTL: 60_000,
    });
  }
}

Generate a GraphQL Service

Terminal
okalit -g --gqservice ./src/services/users

Creates src/services/users.service.js:

users.service.js
import { OkalitGraphqlService, service } from "@okalit/core";

@service("usersService")
export class UsersService extends OkalitGraphqlService {
  constructor() {
    super();
    this.configure({
      endpoint: '',
      cache: true,
      cacheTTL: 120_000,
    });
  }
}

Generate a Guard

Terminal
okalit -g --guard ./src/guards/auth

Creates src/guards/auth.guard.js:

auth.guard.js
export async function authGuard() {
  return true;
}

Smart Detection

The CLI automatically detects your project configuration:

DetectionHowEffect
Style extensionChecks if sass is in dependenciesUses .scss or .css
Global stylesLooks for src/styles/global.scssAdds @styles/global.scss?inline import
Core aliasAlways @okalit/coreUsed in all imports
Project rootWalks up to find package.jsonResolves all paths relative to root

Full CLI Reference

okalit --help
Usage:
  okalit new <app-name>
  okalit generate <type> <path>

Commands:
  new <name>                Create a new app

  generate, -g
    -c, --component <path> Create a component
    -p, --page <path>      Create a page
    -s, --service <path>   Create a service
        --gqservice <path> Create a GraphQL service
    -m, --module <path>    Create a module
        --guard <path>     Create a guard

Examples:
  okalit new my-app
  okalit -g -c ./src/components/user-card
  okalit -g -s ./src/modules/home/services/user
  okalit -g -m ./src/modules/billing
  okalit -g --guard ./src/guards/auth

Project Structure

Okalit follows an opinionated modular architecture:

Recommended Structure
src/
├── main-app.js              # App entry (AppMixin)
├── app.routes.js            # Root route definitions
├── channels/                # Global state channels
│   ├── session.channel.js
│   └── data.channel.js
├── components/              # Shared UI components
│   ├── atoms/               # Basic elements (button, input, badge)
│   ├── molecules/           # Composed elements
│   └── organisms/           # Complex UI blocks
├── layouts/                 # Layout shells
├── modules/                 # Feature modules
│   ├── home/
│   │   ├── home.module.js   # ModuleMixin entry
│   │   ├── home.routes.js   # Module routes
│   │   └── pages/           # Module pages (PageMixin)
│   └── auth/
├── services/                # API services
│   ├── auth.service.js
│   └── posts.service.js
└── styles/                  # Global styles
LayerMixinPurpose
AppAppMixinRoot. Initializes router, i18n, and config.
ModuleModuleMixinFeature boundary. Groups related pages.
PagePageMixinRouted view. Accesses params and query.
ComponentOkalitReusable UI element. No routing awareness.

Okalit Base Class

All Okalit components extend the Okalit class, which extends Lit's LitElement with signal reactivity, channels, and lifecycle hooks.

my-component.js
import { Okalit, html, defineElement } from '@okalit/core';
import styles from './my-component.scss?inline';

@defineElement({
  tag: 'my-component',
  styles: [styles],
  props: [
    { count: { type: Number, value: 0 } },
    { label: { type: String, value: 'Hello' } },
  ]
})
export class MyComponent extends Okalit {
  onInit() {
    // Called when component connects to DOM
  }

  render() {
    return html`
      <p>${this.label.value}: ${this.count.value}</p>
      <button @click=${() => this.count.value++}>+1</button>
    `;
  }
}

Key Concepts

  • Props are signals — access with .value, set with .value = x
  • Rendering is wrapped in an effect() — signal reads inside render() are automatically tracked
  • Styles use CSSStyleSheet (Adopted Stylesheets) — shared across all instances of the same component
  • Shadow DOM is enabled by default (inherits from LitElement)

@defineElement Decorator

Registers your class as a Custom Element and configures its styles, props, and observed attributes.

Signature
@defineElement({
  tag: string,           // Custom element tag name (must contain a dash)
  styles?: string[],     // Array of CSS strings (use ?inline imports)
  props?: PropDef[],     // Reactive property definitions
})

Props Definition

Each prop is an object with a single key (the prop name) mapping to its config:

Props Config
props: [
  { myProp: { type: String, value: 'default' } },
  { count:  { type: Number, value: 0 } },
  { active: { type: Boolean, value: false } },
]
FieldTypeDescription
typeString | Number | BooleanUsed for attribute coercion from HTML
valueanyInitial default value for the signal

Prop names are automatically converted to kebab-case for HTML attributes: myPropmy-prop.

Signals & Reactivity

Okalit uses uhtml's signal primitives for fine-grained reactivity. Signals are the core unit of state.

Imports
import { signal, computed, effect, batch } from '@okalit/core';

signal(initialValue)

Creates a reactive atom. Read and write with .value.

Example
const count = signal(0);
console.log(count.value); // 0
count.value = 5;         // triggers dependent effects/renders

computed(fn)

Creates a derived signal. Automatically recomputes when dependencies change.

Example
const doubled = computed(() => count.value * 2);
console.log(doubled.value); // 10

effect(fn)

Runs a side effect whenever its signal dependencies change. Returns a dispose function.

Example
const dispose = effect(() => {
  console.log('Count is:', count.value);
});

// Stop tracking:
dispose();

batch(fn)

Groups multiple signal writes into a single notification, preventing intermediate renders.

Example
batch(() => {
  name.value = 'Alice';
  age.value = 30;
  // Only one re-render triggered after both changes
});

Signals in Components

Props declared in @defineElement are automatically wrapped in signals. The render() method is wrapped in an effect(), so any signal read inside it triggers re-render when that signal changes.

Automatic tracking
render() {
  // Reading this.count.value registers this component
  // as a subscriber of the count signal
  return html`<p>${this.count.value}</p>`;
}

Lifecycle Hooks

Okalit provides clean lifecycle hooks that wrap Lit's internal callbacks:

HookWhen it runs
onInit()Component connected to DOM (connectedCallback)
onChange(changes)When any prop signal value changes. Receives { propName: { previous, current } }
onBeforeRender(changedProperties)Before each render cycle (willUpdate)
onFirstRender(changedProperties)After the very first DOM render (firstUpdated)
onAfterRender(changedProperties)After each render cycle (updated)
onDestroy()Component disconnected from DOM (disconnectedCallback)
Example
export class UserCard extends Okalit {
  onInit() {
    console.log('Component mounted');
  }

  onChange(changes) {
    if (changes.userId) {
      console.log('User changed from', changes.userId.previous, 'to', changes.userId.current);
    }
  }

  onDestroy() {
    console.log('Component unmounted');
  }
}

output(name, detail)

Utility method to dispatch a CustomEvent with bubbles: true and composed: true:

Emitting events
// Inside a component method:
this.output('on:select', { id: 42 });

// Parent listens:
html`<child-el @on:select=${this.handleSelect}></child-el>`

Props System

Props in Okalit are reactive signals exposed as element properties and HTML attributes.

Defining Props

Declaration
@defineElement({
  tag: 'user-avatar',
  props: [
    { src:  { type: String, value: '' } },
    { size: { type: Number, value: 48 } },
    { online: { type: Boolean, value: false } },
  ]
})

Reading & Writing

Usage
// Read (inside render or effect):
this.size.value   // → 48

// Write (triggers re-render):
this.size.value = 64;

// From HTML attribute:
// <user-avatar size="96" online></user-avatar>

Attribute Coercion

When set via HTML attribute, values are automatically coerced:

  • String — used as-is
  • Number — parsed with Number(value)
  • Booleantrue if attribute exists and is not "false"

Mixins (App, Module, Page)

Okalit provides three mixins that define the architectural layers of your application.

AppMixin

The root application component. Initializes router, i18n, and app-level config.

main-app.js
import { AppMixin, Okalit, defineElement, html } from '@okalit/core';
import routes from './app.routes.js';

@defineElement({ tag: 'main-app' })
export class MainApp extends AppMixin(Okalit) {
  static config = {
    routes,
    modeDebug: false,
    obfuscateChannels: true,
    i18n: { locales: ['en', 'es'], default: 'en', basePath: '/i18n' },
    template: (content) => html`<app-layout>${content}</app-layout>`,
  };
}
Config OptionTypeDescription
routesArrayRoot route definitions
modeDebugBooleanEnables channel debug logging
obfuscateChannelsBooleanScrambles channel data in storage
i18nObjecti18n configuration (locales, default, basePath)
templateFunctionLayout wrapper for the router outlet

ModuleMixin

Groups related pages under a feature boundary. Provides a nested <okalit-router> for child routes.

home.module.js
import { ModuleMixin, Okalit, defineElement } from '@okalit/core';

@defineElement({ tag: 'home-module' })
export class HomeModule extends ModuleMixin(Okalit) {}

PageMixin

Routed view components. Provides route params, query params, and navigation helpers.

home.page.js
import { PageMixin, Okalit, defineElement, html } from '@okalit/core';

@defineElement({ tag: 'home-page' })
export class HomePage extends PageMixin(Okalit) {
  render() {
    return html`<h1>Welcome home</h1>`;
  }
}

PageMixin API

Property / MethodDescription
this.routeParamsCurrent route params object (e.g. { id: '42' })
this.queryParamsCurrent URL query params object
this.safeParam(name)HTML-escaped route param (safe for innerHTML)
this.safeQuery(name)HTML-escaped query param
this.navigate(path, opts)Navigate to a route
this.backRoute()Go back in history

Router

Built-in SPA router with lazy loading, nested routes, dynamic params, and navigation guards.

Route Definition

app.routes.js
export default [
  {
    path: '/',
    component: 'home-module',
    import: () => import('./modules/home/home.module.js'),
    children: [
      {
        path: '',
        component: 'home-page',
        import: () => import('./modules/home/pages/home.page.js'),
      },
      {
        path: '/users/:id',
        component: 'user-page',
        import: () => import('./modules/home/pages/user.page.js'),
      },
    ],
  },
  {
    path: '/admin',
    component: 'admin-module',
    import: () => import('./modules/admin/admin.module.js'),
    guards: [authGuard],
    children: [...],
  },
];

Route Config Object

FieldTypeDescription
pathstringURL pattern. Supports :param dynamic segments.
componentstringCustom element tag to render
importFunctionLazy loader (dynamic import)
childrenRoute[]Nested child routes
guardsFunction[]Navigation guards (run before loading)

Navigation Guards

Guards are async functions that control route access. They can allow, block, or redirect.

auth.guard.js
export function authGuard({ path, params, route }) {
  const token = getChannel('session:token')?.value;

  if (!token) return '/auth/login'; // redirect
  return true;                         // allow
  // return false;                      // block silently
}

Programmatic Navigation

Usage
// Inside any component with PageMixin or ModuleMixin:
this.navigate('/users/42');
this.navigate('/login', { replace: true });
this.backRoute();

// From anywhere (helper function):
import { navigate } from '@okalit/core';
navigate('/dashboard');

Channel Scopes & Router

The router automatically clears scoped channels on navigation:

  • scope: 'page' — cleared when navigating to a different page
  • scope: 'module' — cleared when navigating to a different module
  • scope: 'app' — never cleared automatically

Channels (Global State)

Channels provide reactive, cross-component state management without prop-drilling. They support persistence, scoping, validation, and pub/sub patterns.

Defining a Channel

session.channel.js
import { defineChannel } from '@okalit/core';

export const sessionToken = defineChannel('session:token', {
  initialValue: null,
  persist: 'local',     // 'memory' | 'local' | 'session'
  scope: 'app',         // 'app' | 'module' | 'page'
});

export const userProfile = defineChannel('session:user', {
  initialValue: { name: '', email: '', avatar: '' },
  persist: 'local',
  scope: 'app',
  validate: (value) => value && typeof value.email === 'string',
});

// Ephemeral channel (event bus, no state stored)
export const toastEvent = defineChannel('ui:toast', {
  ephemeral: true,
  scope: 'app',
});

Channel Options

OptionDefaultDescription
initialValueDefault value (ignored if ephemeral)
persist'memory'Storage: 'memory', 'local', or 'session'
scope'app'Auto-clear: 'app', 'module', or 'page'
ephemeralfalseIf true, acts as event bus (no persistent state)
validateCustom validator function for stored values

Using Channels in Components

profile.page.js
import { sessionToken, userProfile, toastEvent } from '../../channels/session.channel.js';

@defineElement({ tag: 'profile-page' })
export class ProfilePage extends PageMixin(Okalit) {
  // Declare channels with optional method callbacks
  static channels = {
    token: sessionToken(),                    // read/write handle
    user: userProfile('onUserChange'),        // + subscribe to changes
    toast: toastEvent('onToast'),             // ephemeral subscription
  };

  onUserChange(value) {
    console.log('User updated:', value);
  }

  onToast(message) {
    // Show toast notification
  }

  render() {
    return html`
      <p>Hello, ${this.user.value?.name}</p>
      <button @click=${() => this.user.set({ ...this.user.value, name: 'New' })}>
        Update name
      </button>
      <button @click=${() => this.toast.set('Profile saved!')}>
        Show toast
      </button>
    `;
  }
}

Channel Handle API

MethodDescription
handle.valueRead current value (reactive — triggers render when read inside render())
handle.set(value)Set new value (notifies all subscribers, persists if configured)
handle.reset()Reset to initialValue and clear storage

Reading Channels Outside Components

auth.guard.js
import { getChannel, getChannelValueStorage } from '@okalit/core';

// Read from active channel (runtime)
const token = getChannel('session:token')?.value;

// Read directly from storage (before channels are initialized)
const stored = getChannelValueStorage('session:token', 'local');

Forms & Validation

Okalit includes a reactive form system with built-in validators, powered by signals.

Creating a Form

login.page.js
import { createForm, required, email, minLength } from '@okalit/core/form';

const form = createForm({
  fields: {
    email:    { value: '', validators: [required(), email()] },
    password: { value: '', validators: [required(), minLength(8)] },
  },
  validateOn: 'blur',  // 'input' | 'blur' | 'submit'
});

Form API

Property / MethodDescription
form.fieldsObject with per-field signals: { value, error, touched, dirty }
form.validComputed signal — true if all fields have no errors
form.dirtyComputed signal — true if any field differs from initial
form.errorsComputed — object of all current error messages
form.validate()Validate all fields, returns boolean
form.validateField(name)Validate a single field
form.values()Get all values as plain object
form.reset()Reset all fields to initial values
form.setErrors(map)Set server-side errors: { email: 'Already taken' }
form.handleInput(event)Event handler for input changes (expects detail.name + detail.value)
form.handleBlur(event)Event handler for blur (expects detail.name)

Built-in Validators

ValidatorSignatureDescription
requiredrequired(msg?)Value must be non-empty
emailemail(msg?)Must match email format
minLengthminLength(n, msg?)Minimum string length
maxLengthmaxLength(n, msg?)Maximum string length
minmin(n, msg?)Minimum numeric value
maxmax(n, msg?)Maximum numeric value
patternpattern(regex, msg?)Must match regex
matchmatch(getOtherValue, msg?)Must equal another field's value

Full Form Example

register.page.js
import { createForm, required, email, minLength, match } from '@okalit/core/form';

form = createForm({
  fields: {
    email:    { value: '', validators: [required(), email()] },
    password: { value: '', validators: [required(), minLength(8)] },
    confirm:  { value: '', validators: [
      required(),
      match(() => this.form.fields.password.value.value, 'Passwords do not match'),
    ]},
  },
});

handleSubmit() {
  if (!this.form.validate()) return;

  inject('auth').register(this.form.values()).fire({
    onSuccess: () => this.navigate('/home'),
    onError: (err) => this.form.setErrors(err.body),
  });
}

render() {
  const { email, password, confirm } = this.form.fields;
  return html`
    <input-atom name="email"
      .value=${email.value.value}
      .error=${email.error.value}
      @on:change=${this.form.handleInput}
      @on:blur=${this.form.handleBlur}></input-atom>

    <button-atom
      .disabled=${!this.form.valid.value}
      @on:click=${this.handleSubmit}>Register</button-atom>
  `;
}

Internationalization (i18n)

Reactive, dictionary-driven translations. Language switching updates all components automatically without page reload.

Setup

Configure in your AppMixin static config:

main-app.js
static config = {
  i18n: {
    locales: ['en', 'es', 'fr'],
    default: 'en',
    basePath: '/i18n',  // will fetch /i18n/en.json, /i18n/es.json, etc.
  },
};

Translation Files

/i18n/en.json
{
  "NAV": {
    "HOME": "Home",
    "PROFILE": "Profile"
  },
  "WELCOME": "Welcome back, {{name}}!"
}

Using t() in Templates

Component usage
import { t } from '@okalit/core';

render() {
  return html`
    <h1>${t('WELCOME', { name: 'Alice' })}</h1>
    <nav>
      <a href="/">${t('NAV.HOME')}</a>
      <a href="/profile">${t('NAV.PROFILE')}</a>
    </nav>
  `;
}

t() is reactive — it reads internal locale signals, so any component using t() will re-render when the language changes.

Switching Locale

Language switcher
import { getI18n } from '@okalit/core';

// Programmatic switch:
getI18n().setLocale('es');

// Or from AppMixin:
this.switchLocale('es');

i18n Features

  • Nested key support: t('NAV.HOME')
  • Parameter interpolation: t('WELCOME', { name: 'Alice' }) → replaces {{name}}
  • Auto-detects browser locale from navigator.language
  • Persists locale choice in localStorage
  • Lazy-loads dictionaries on demand
  • Fallback to default locale if key not found

Services & Dependency Injection

Services are singleton classes registered via the @service decorator and retrieved with inject(). They encapsulate API calls and business logic.

The Pattern

auth.service.js
import { OkalitService, service, inject } from '@okalit/core';

@service('auth')
class AuthService extends OkalitService {
  constructor() {
    super();
    this.configure({
      baseUrl: 'https://api.example.com',
    });
  }

  login(credentials) { return this.post('/auth/login', credentials); }
  register(data)     { return this.post('/auth/register', data); }
}

// Usage anywhere:
inject('auth').login({ email, password }).fire({
  onLoading: (v) => ...,
  onSuccess: (data) => ...,
  onError: (err) => ...,
});

RequestControl

All service methods return a RequestControl — a thenable wrapper that supports both declarative (.fire()) and imperative (await) patterns:

Two usage patterns
// Pattern 1: Declarative with .fire()
inject('posts').getAll().fire({
  onLoading: (loading) => this.loading.value = loading,
  onSuccess: (data) => this.posts.value = data,
  onError: (err) => this.error.value = err.message,
  onFinish: () => console.log('done'),
});

// Pattern 2: Imperative with await
const data = await inject('posts').getAll();

REST Service (OkalitService)

Full-featured HTTP client with caching, rate limiting, retry, interceptors, and timeout.

Configuration

configure() options
this.configure({
  baseUrl: 'https://api.example.com/v1',
  headers: { 'X-App': 'my-app' },
  cache: true,                        // enable in-memory cache for GET
  cacheTTL: 30000,                    // cache lifetime in ms (0 = forever)
  timeout: 10000,                     // request timeout in ms
  rateLimit: { maxRequests: 10, perSeconds: 1 },
  retry: { attempts: 3, backoff: 1000, factor: 2 },
  interceptors: [...],               // request interceptors
  responseInterceptors: [...],       // response interceptors
});

HTTP Methods

MethodSignatureReturns
getget(path, params?)RequestControl (cacheable)
postpost(path, body)RequestControl
putput(path, body)RequestControl
patchpatch(path, body)RequestControl
deletedelete(path)RequestControl
clearCacheclearCache(path?)void

Interceptors

Interceptors modify requests before they're sent or responses after they're received.

Bearer token interceptor
this.configure({
  interceptors: [
    ({ url, options }) => {
      const token = getChannel('session:token')?.value;
      if (token) {
        options.headers['Authorization'] = `Bearer ${token}`;
      }
      return { url, options };
      // return null; → cancels the request
    }
  ],
  responseInterceptors: [
    ({ data, error }) => {
      if (error?.status === 401) {
        navigate('/auth/login');
      }
      return { data, error };
    }
  ],
});

Retry & Rate Limiting

  • Retry — Only retries on HTTP 5xx errors and timeouts. Uses exponential backoff.
  • Rate Limiting — Token bucket algorithm. Queues requests when limit is exceeded.

Error Handling

HttpError
import { HttpError } from '@okalit/core';

// HttpError properties:
err.status     // HTTP status code (e.g. 404)
err.statusText // Status text (e.g. 'Not Found')
err.body       // Parsed JSON body from server

GraphQL Service

Extends BaseService with GraphQL-specific query/mutation methods, caching, and the gql template tag.

users.graphql.js
import { OkalitGraphqlService, service, gql } from '@okalit/core';

@service('users-gql')
class UsersGraphql extends OkalitGraphqlService {
  constructor() {
    super();
    this.configure({
      endpoint: 'https://api.example.com/graphql',
      cache: true,
      cacheTTL: 60000,
    });
  }

  getUser(id) {
    return this.query(gql`
      query GetUser($id: ID!) {
        user(id: $id) { id name email avatar }
      }
    `, { id });
  }

  updateProfile(input) {
    return this.mutate(gql`
      mutation UpdateProfile($input: ProfileInput!) {
        updateProfile(input: $input) { id name }
      }
    `, { input });
  }
}

API

MethodSignatureDescription
queryquery(gqlString, variables?)Execute a GraphQL query (cacheable)
mutatemutate(gqlString, variables?)Execute a GraphQL mutation

Important: Always pass dynamic values as variables (second argument), never interpolate user input directly into the gql template string. The gql tag is for static query structure only.

WebSocket Service

Full-featured WebSocket client with auto-reconnect, exponential backoff, keepalive pings, and message interceptors.

chat.socket.js
import { OkalitSocketService, service } from '@okalit/core';

@service('chat')
class ChatService extends OkalitSocketService {
  constructor() {
    super();
    this.configure({
      url: 'wss://ws.example.com/chat',
      reconnect: true,
      reconnectInterval: 2000,
      reconnectMaxInterval: 30000,
      maxReconnectAttempts: 10,
      pingInterval: 30000,
    });
  }

  async join(roomId) {
    await this.connect();
    await this.send('join', { roomId });
  }

  sendMessage(roomId, text) {
    this.send('message', { roomId, text });
  }

  onNewMessage(callback) {
    return this.on('message', callback);
  }

  leave() { this.disconnect(); }
}

Configuration

OptionDefaultDescription
urlWebSocket server URL (ws:// or wss://)
protocols[]Sub-protocols
reconnecttrueAuto-reconnect on disconnect
reconnectInterval1000Initial reconnect delay (ms)
reconnectMaxInterval30000Max backoff delay (ms)
maxReconnectAttemptsInfinityMax retry attempts
pingInterval0Keepalive ping interval (0 = disabled)
serializerJSON.stringifyMessage serializer
deserializerJSON.parseMessage deserializer

API

MethodDescription
connect(url?)Open connection. Returns Promise.
disconnect(code?, reason?)Close connection gracefully.
send(event, payload)Send structured message { event, payload }.
sendRaw(data)Send raw data without event wrapper.
on(event, callback)Subscribe. Returns unsubscribe function.
once(event, callback)Subscribe once.
off(event, callback)Unsubscribe.
stateCurrent state: 'CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'
connectedBoolean — whether socket is open and ready.

Events

  • 'open' — connection established
  • 'close' — connection closed
  • 'error' — connection error
  • 'message' — incoming message (deserialized)
  • 'reconnecting' — reconnect attempt { attempt, delay }
  • 'reconnect_failed' — max attempts reached

gRPC Service

gRPC-Web client with metadata interceptors, unary calls, and server-streaming support.

users.grpc.js
import { OkalitGrpcService, service } from '@okalit/core';
import { UserServiceClient } from './proto/user_grpc_web_pb';
import { GetUserRequest } from './proto/user_pb';

@service('users-grpc')
class UsersGrpcService extends OkalitGrpcService {
  constructor() {
    super();
    this.configure({
      host: 'https://grpc.example.com',
      metadata: { 'x-app': 'my-app' },
    });
  }

  getUser(id) {
    const req = new GetUserRequest();
    req.setId(id);
    return this.unary(UserServiceClient, 'getUser', req);
  }

  streamUpdates(userId) {
    const req = new GetUserRequest();
    req.setId(userId);
    return this.serverStream(UserServiceClient, 'watchUser', req);
  }
}

API

MethodReturnsDescription
unary(Client, method, request, metadata?)RequestControlSingle request/response RPC
serverStream(Client, method, request, metadata?)StreamControlServer-streaming RPC
setMetadata(key, value)voidUpdate default metadata
removeMetadata(key)voidRemove metadata key
client(ClientClass)clientGet/create cached client instance

StreamControl

Server streaming usage
const stream = inject('users-grpc').streamUpdates('user-1');

stream.listen({
  onData: (msg) => console.log('Update:', msg),
  onError: (err) => console.error(err),
  onEnd: () => console.log('Stream ended'),
  onStatus: (status) => console.log('Status:', status),
});

// Cancel stream:
stream.cancel();

GrpcError & GrpcStatus

Error handling
import { GrpcError, GrpcStatus } from '@okalit/core';

// GrpcError properties:
err.code         // numeric status code
err.grpcMessage  // error message from server
err.metadata     // response metadata

// GrpcStatus enum:
GrpcStatus.OK               // 0
GrpcStatus.NOT_FOUND        // 5
GrpcStatus.PERMISSION_DENIED // 7
GrpcStatus.UNAUTHENTICATED  // 16

Lazy Loading Elements

Okalit provides three custom elements for deferred loading of heavy components, optimizing initial bundle size and perceived performance.

<o-idle> — Load on Browser Idle

Loads content when the browser is idle (via requestIdleCallback). Falls back to a 200ms timeout.

Usage
const idle = document.querySelector('o-idle');
idle.loader = () => import('./heavy-chart.js');

<!-- In HTML: -->
<o-idle>
  <heavy-chart></heavy-chart>
  <span slot="fallback">Loading chart...</span>
</o-idle>

<o-when> — Load on Condition

Loads content when a boolean condition becomes true.

Usage
const el = document.querySelector('o-when');
el.loader = () => import('./admin-panel.js');
el.condition = user.isAdmin; // loads only when true

<o-viewport> — Load on Scroll into View

Loads content when the element enters the viewport (via IntersectionObserver, with 200px root margin).

Usage
const el = document.querySelector('o-viewport');
el.loader = [
  () => import('./comments-section.js'),
  () => import('./comment-item.js'),
];

<!-- In HTML: -->
<o-viewport>
  <comments-section></comments-section>
  <div slot="fallback">
    <skeleton-loader></skeleton-loader>
  </div>
</o-viewport>

Common Behavior

  • loader accepts a function or array of functions (resolved with Promise.all)
  • Adds [loaded] attribute when complete — default slot shown, fallback hidden
  • Dispatches o-error event (bubbles + composed) on failure
  • Loads only once — subsequent reconnections don't re-trigger

Utilities

escapeHtml(str)

Escapes HTML special characters. Use when inserting user data into innerHTML (not needed with Lit's html`` — it escapes by default).

Usage
import { escapeHtml } from '@okalit/core';

escapeHtml('<script>alert("xss")</script>');
// → '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'

queryShadowSelector(path, context?)

Traverse nested shadow DOMs with a >>-separated selector path.

Usage
import { queryShadowSelector } from '@okalit/core';

// Finds #my-id inside the shadow root of 'inner-element'
const el = queryShadowSelector('my-component >> inner-element >> #my-id');

outEvent(context, name, detail)

Standalone helper to dispatch a composed CustomEvent from any context.

Usage
import { outEvent } from '@okalit/core';

outEvent(this, 'on:submit', { formData });
// Equivalent to this.output('on:submit', { formData })

gql`...`

Template tag that normalizes whitespace in GraphQL query strings. For static structure only — pass variables through the service method's second argument.

Usage
import { gql } from '@okalit/core';

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) { id name email }
  }
`;

// Pass variables separately:
this.query(GET_USER, { id: '42' });