Skip to content

Modules

A module is a value with a name and one or more of: event handlers, command handlers, providers, and lifecycle hooks. You install it on the runtime; it observes events, handles commands, exposes services, and the UI never imports it.

import { defineKitModule } from '@atheory-ai/kitsune-core'
export const auditModule = defineKitModule({
name: 'audit',
events: {
'note.saved': (event) => writeAudit(event),
'note.deleted': (event) => writeAudit(event),
},
})

That’s a module. Three properties.

A module has six possible fields:

export type KitModule = {
name: string
setup?: (runtime: KitRuntime) => void | Promise<void>
start?: (runtime: KitRuntime) => void | Promise<void>
stop?: (runtime: KitRuntime) => void | Promise<void>
events?: Record<string, EventHandler>
commands?: Record<string, CommandHandler>
}

name — for diagnostics and (eventually) devtools.

setup — runs at install time. Use this to register provider tokens, prepare state, do work that can be done synchronously before the runtime starts.

start — runs after the shell has installed all modules and the runtime is ready. Use this for I/O that depends on other modules being installed.

stop — runs when the runtime tears down. Cleanup goes here.

events — a map of event type → handler. Multiple modules can subscribe to the same type; all run.

commands — a map of command type → handler. The latest registered handler wins.

const shell = document.querySelector('kit-shell')
shell.modules = [auditModule, notificationModule(), debugModule()]

Or programmatically:

const runtime = createKitRuntime()
await runtime.install(auditModule)
await runtime.start()

Order doesn’t matter for events. For commands, the last installed wins — useful for overriding default modules in tests.

Modules can hold their own state.

import { defineKitModule } from '@atheory-ai/kitsune-core'
export function auditModule() {
const log: Array<{ type: string; timestamp: number }> = []
return defineKitModule({
name: 'audit',
events: {
'*': (event) => {
log.push({ type: event.type, timestamp: event.timestamp })
if (log.length > 100) log.shift()
},
},
})
}

Closure-scoped state stays private to the module. No global registries, no implicit access.

Sometimes a module needs to expose a service that other modules or UI code can read. Use a provider token.

import { createProviderToken, defineKitModule } from '@atheory-ai/kitsune-core'
export type StorageService = {
get(key: string): unknown
set(key: string, value: unknown): void
}
export const StorageToken = createProviderToken<StorageService>('storage')
export function localStorageModule() {
return defineKitModule({
name: 'localStorage',
setup(runtime) {
runtime.provide(StorageToken, {
get: (k) => JSON.parse(localStorage.getItem(k) ?? 'null'),
set: (k, v) => localStorage.setItem(k, JSON.stringify(v)),
})
},
})
}

Another module can read it:

const storage = runtime.inject(StorageToken)
storage?.set('quill.notes', notes)

This is how modules cooperate without importing each other.

Sometimes a module needs to emit an event or dispatch a command of its own.

export const draftAutosaveModule = defineKitModule({
name: 'draft-autosave',
setup(runtime) {
let pending: ReturnType<typeof setTimeout> | undefined
runtime.on('draft.changed', () => {
clearTimeout(pending)
pending = setTimeout(() => {
runtime.command({ type: 'draft.save' })
}, 500)
})
},
})

The autosave module observes draft.changed and dispatches draft.save after a debounce. Whoever handles draft.save (a localStorageModule, an HTTP module, a Supabase module) does the actual work.

This is the composability payoff. Three modules — one observes, one debounces, one persists — together implement autosave. Any of them can be replaced.

A module that registers commands at runtime

Section titled “A module that registers commands at runtime”

Use setup to register handlers programmatically when you need conditional logic:

export function clipboardModule(options: { fallback?: 'noop' | 'alert' } = {}) {
return defineKitModule({
name: 'clipboard',
setup(runtime) {
if (navigator.clipboard) {
runtime.handleCommand('clipboard.copy', async (cmd) => {
await navigator.clipboard.writeText(String(cmd.payload?.text ?? ''))
return { copied: true }
})
} else if (options.fallback === 'alert') {
runtime.handleCommand('clipboard.copy', (cmd) => {
window.alert('Copy: ' + cmd.payload?.text)
return { copied: false, fallback: 'alert' }
})
}
},
})
}

Modules are values. Test them by installing them on a fresh runtime.

import { describe, it, expect } from 'vitest'
import { createKitRuntime } from '@atheory-ai/kitsune-core'
import { auditModule } from './audit-module'
describe('auditModule', () => {
it('records note.saved', async () => {
const runtime = createKitRuntime()
await runtime.install(auditModule())
await runtime.start()
runtime.emit({ type: 'note.saved', payload: { id: '42' } })
// assert against the module's exposed state or a spy
})
})

No DOM required. No browser required. Modules are pure runtime values.

  • kit- prefix for built-in modules: kit-dialog, kit-notifications, kit-debug.
  • Use kebab-case.
  • Keep names short and refer to the capability, not the implementation. analytics, not posthog-analytics-v2.