Okalit Framework
A progressive Web Components framework built on Lit, with signal-based reactivity via uhtml, modern TC39 decorators, and enterprise-grade modular architecture.
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)
npx @okalit/cli new my-app cd my-app npm install npm run dev
Manual Setup
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.
npm install -g @okalit/cli
Create a New Project
okalit new my-app
Or with npx (no global install):
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.
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.
| Flag | Alias | What it generates |
|---|---|---|
-c | --component | Component (JS + stylesheet) |
-p | --page | Page with PageMixin (JS + stylesheet) |
-s | --service | REST service (OkalitService) |
--gqservice | — | GraphQL service (OkalitGraphqlService) |
-m | --module | Full module (module + routes + default page) |
--guard | — | Navigation guard function |
Generate a Component
okalit -g -c ./src/components/atoms/user-card
Creates:
src/components/atoms/user-card/ ├── user-card.js # Component class extending Okalit └── user-card.scss # Empty stylesheet (scss or css auto-detected)
Generated code:
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
okalit -g -p ./src/modules/home/pages/settings
Creates:
src/modules/home/pages/settings/ ├── settings.js # Page class with PageMixin └── settings.scss # Empty stylesheet
Generated code:
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.
okalit -g -m ./src/modules/billing
Creates:
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:
import { ModuleMixin, Okalit, defineElement } from "@okalit/core"; @defineElement({ tag: "billing-module" }) export class BillingModule extends ModuleMixin(Okalit) {}
export default [ { path: "", component: "billing-page", import: () => import("./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
okalit -g -s ./src/services/posts
Creates src/services/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
okalit -g --gqservice ./src/services/users
Creates src/services/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
okalit -g --guard ./src/guards/auth
Creates src/guards/auth.guard.js:
export async function authGuard() { return true; }
Smart Detection
The CLI automatically detects your project configuration:
| Detection | How | Effect |
|---|---|---|
| Style extension | Checks if sass is in dependencies | Uses .scss or .css |
| Global styles | Looks for src/styles/global.scss | Adds @styles/global.scss?inline import |
| Core alias | Always @okalit/core | Used in all imports |
| Project root | Walks up to find package.json | Resolves all paths relative to root |
Full CLI Reference
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:
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
| Layer | Mixin | Purpose |
|---|---|---|
| App | AppMixin | Root. Initializes router, i18n, and config. |
| Module | ModuleMixin | Feature boundary. Groups related pages. |
| Page | PageMixin | Routed view. Accesses params and query. |
| Component | Okalit | Reusable 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.
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 insiderender()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.
@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: [
{ myProp: { type: String, value: 'default' } },
{ count: { type: Number, value: 0 } },
{ active: { type: Boolean, value: false } },
]
| Field | Type | Description |
|---|---|---|
| type | String | Number | Boolean | Used for attribute coercion from HTML |
| value | any | Initial default value for the signal |
Prop names are automatically converted to kebab-case for HTML attributes: myProp → my-prop.
Signals & Reactivity
Okalit uses uhtml's signal primitives for fine-grained reactivity. Signals are the core unit of state.
import { signal, computed, effect, batch } from '@okalit/core';
signal(initialValue)
Creates a reactive atom. Read and write with .value.
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.
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.
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.
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.
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:
| Hook | When 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) |
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:
// 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
@defineElement({
tag: 'user-avatar',
props: [
{ src: { type: String, value: '' } },
{ size: { type: Number, value: 48 } },
{ online: { type: Boolean, value: false } },
]
})
Reading & Writing
// 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-isNumber— parsed withNumber(value)Boolean—trueif 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.
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 Option | Type | Description |
|---|---|---|
routes | Array | Root route definitions |
modeDebug | Boolean | Enables channel debug logging |
obfuscateChannels | Boolean | Scrambles channel data in storage |
i18n | Object | i18n configuration (locales, default, basePath) |
template | Function | Layout wrapper for the router outlet |
ModuleMixin
Groups related pages under a feature boundary. Provides a nested <okalit-router> for child routes.
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.
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 / Method | Description |
|---|---|
this.routeParams | Current route params object (e.g. { id: '42' }) |
this.queryParams | Current 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
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
| Field | Type | Description |
|---|---|---|
path | string | URL pattern. Supports :param dynamic segments. |
component | string | Custom element tag to render |
import | Function | Lazy loader (dynamic import) |
children | Route[] | Nested child routes |
guards | Function[] | Navigation guards (run before loading) |
Navigation Guards
Guards are async functions that control route access. They can allow, block, or redirect.
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
// 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 pagescope: 'module'— cleared when navigating to a different modulescope: '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
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
| Option | Default | Description |
|---|---|---|
initialValue | — | Default value (ignored if ephemeral) |
persist | 'memory' | Storage: 'memory', 'local', or 'session' |
scope | 'app' | Auto-clear: 'app', 'module', or 'page' |
ephemeral | false | If true, acts as event bus (no persistent state) |
validate | — | Custom validator function for stored values |
Using Channels in Components
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
| Method | Description |
|---|---|
handle.value | Read 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
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
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 / Method | Description |
|---|---|
form.fields | Object with per-field signals: { value, error, touched, dirty } |
form.valid | Computed signal — true if all fields have no errors |
form.dirty | Computed signal — true if any field differs from initial |
form.errors | Computed — 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
| Validator | Signature | Description |
|---|---|---|
required | required(msg?) | Value must be non-empty |
email | email(msg?) | Must match email format |
minLength | minLength(n, msg?) | Minimum string length |
maxLength | maxLength(n, msg?) | Maximum string length |
min | min(n, msg?) | Minimum numeric value |
max | max(n, msg?) | Maximum numeric value |
pattern | pattern(regex, msg?) | Must match regex |
match | match(getOtherValue, msg?) | Must equal another field's value |
Full Form Example
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:
static config = { i18n: { locales: ['en', 'es', 'fr'], default: 'en', basePath: '/i18n', // will fetch /i18n/en.json, /i18n/es.json, etc. }, };
Translation Files
{
"NAV": {
"HOME": "Home",
"PROFILE": "Profile"
},
"WELCOME": "Welcome back, {{name}}!"
}
Using t() in Templates
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
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
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:
// 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
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
| Method | Signature | Returns |
|---|---|---|
get | get(path, params?) | RequestControl (cacheable) |
post | post(path, body) | RequestControl |
put | put(path, body) | RequestControl |
patch | patch(path, body) | RequestControl |
delete | delete(path) | RequestControl |
clearCache | clearCache(path?) | void |
Interceptors
Interceptors modify requests before they're sent or responses after they're received.
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
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.
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
| Method | Signature | Description |
|---|---|---|
query | query(gqlString, variables?) | Execute a GraphQL query (cacheable) |
mutate | mutate(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.
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
| Option | Default | Description |
|---|---|---|
url | — | WebSocket server URL (ws:// or wss://) |
protocols | [] | Sub-protocols |
reconnect | true | Auto-reconnect on disconnect |
reconnectInterval | 1000 | Initial reconnect delay (ms) |
reconnectMaxInterval | 30000 | Max backoff delay (ms) |
maxReconnectAttempts | Infinity | Max retry attempts |
pingInterval | 0 | Keepalive ping interval (0 = disabled) |
serializer | JSON.stringify | Message serializer |
deserializer | JSON.parse | Message deserializer |
API
| Method | Description |
|---|---|
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. |
state | Current state: 'CONNECTING', 'OPEN', 'CLOSING', 'CLOSED' |
connected | Boolean — 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.
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
| Method | Returns | Description |
|---|---|---|
unary(Client, method, request, metadata?) | RequestControl | Single request/response RPC |
serverStream(Client, method, request, metadata?) | StreamControl | Server-streaming RPC |
setMetadata(key, value) | void | Update default metadata |
removeMetadata(key) | void | Remove metadata key |
client(ClientClass) | client | Get/create cached client instance |
StreamControl
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
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.
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.
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).
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
loaderaccepts a function or array of functions (resolved withPromise.all)- Adds
[loaded]attribute when complete — default slot shown, fallback hidden - Dispatches
o-errorevent (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).
import { escapeHtml } from '@okalit/core'; escapeHtml('<script>alert("xss")</script>'); // → '<script>alert("xss")</script>'
queryShadowSelector(path, context?)
Traverse nested shadow DOMs with a >>-separated selector path.
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.
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.
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' });