Develop
This guide covers resource structure, the declare / activate lifecycle in register.ts, dependency declarations (deps), custom blocks and i18n.
1. Resource structure
A resource is a directory under src/resource/<id>/. It is identified by a register.ts at its root:
src/resource/msgx/ # using the built-in msgx as an example
├── register.ts # self-declaration entry (declare / activate)
├── api/ # server API clients
├── stores/ # zustand state
├── hooks/ # React hooks (side effects / subscriptions)
├── modules/ # route page components
├── components/ # reusable components
├── conversation-provider.tsx # conversation provider
├── i18n/
│ ├── zh.json
│ └── en.json
└── package.json # optional: declarative clientx.deps
A resource describes itself in package.json:
{
"name": "@me/hello",
"clientx": {
"id": "hello",
"kind": "app",
"deps": ["client"],
"entry": "register.ts"
}
}
id: resource id, equal to the directory name.kind:app/user/msg/agent/model/content/tool/skill/mcp/site.deps: declarative dependencies (merged with dependencies inferred from source imports).entry: self-declaration entry, alwaysregister.ts.
2. register.ts: declare / activate
Every resource exports two lifecycle functions: declare (synchronous, lightweight) and activate (asynchronous, heavy). The production loader prefers uniform declare/activate and falls back to declare<Id>/activate<Id>:
declare: in the main chunk; synchronously registers identity, routes (lazy components) and pure-data features/site content. No heavy work.activate: dynamically imports heavy implementations (apis, stores, hooks, components, realtime mounts). It is only called when the resource is not disabled (absent) — the gate also controls downloads.
Example from the built-in msgx resource:
// src/resource/msgx/register.ts
import {
resourceRegistry, slotRegistry, featureRegistry, defaultResources, routeRegistry,
} from '@clientx/runtime/lib/kernel/instance'
import { ROUTES } from '@clientx/runtime/lib/kernel/routes'
import type { Resource } from '@clientx/runtime/lib/kernel/types'
export const MSGX_RESOURCE: Resource = {
id: 'msgx',
kind: 'msg',
source: { type: 'config' },
endpoint: '/api/msgx',
dependsOn: ['app', 'user'], // kind-level dependencies
}
export function declareMsgx(): void {
resourceRegistry.declare(MSGX_RESOURCE) // 1. register identity
defaultResources.register(MSGX_RESOURCE) // 2. add to the default resource directory
// 3. register routes (lazy components; the loader only references core queries)
routeRegistry.register('msgx', [
{ id: ROUTES.sessions, path: 'sessions', parent: ROUTES.app,
component: () => import('./modules/session-list-page') },
])
// 4. pure-data feature
featureRegistry.register({ id: 'unread-sync', dependsOn: ['msgx'] })
}
export async function activateMsgx(): Promise<void> {
// 1. dynamically import heavy implementations
const { keysApi, messageApi } = await import('./api/msgx')
const { msgxProvider } = await import('./conversation-provider')
const { SessionsUnreadBadge } = await import('./hooks/sessions-unread-badge')
// 2. register capabilities for other resources
capabilityRegistry.register(MSGX_SEND_CAPABILITY, messageApi)
// 3. register conversation provider and slots
conversationRegistry.register(msgxProvider)
slotRegistry.register('tab-unread:sessions', SessionsUnreadBadge)
// 4. side-effect feature: realtime WebSocket lifecycle
featureRegistry.register({
id: 'realtime',
dependsOn: ['msgx'],
mount: () => {
const connect = () => { /* ... */ }
connect()
return () => { /* cleanup */ }
},
})
}
3. Resource lifecycle
- Boot (
main.tsx/core-loader) callsdeclare()synchronously. declare()registers identity, default resources, routes (lazy) and pure-data features/site content.- Persisted overrides are applied (resources disabled by the user become
absent). - Activate is called only for resources that are not disabled.
activate()registers capabilities, slots, providers and side-effect mounts.- Dependencies are declared/activated before their dependents.
Key invariants:
- declare must be synchronous: identity/routes/pure data must be ready on the first frame; activate may be asynchronous.
- The gate controls downloads: resources disabled by the user are filtered before import/declare/activate — they are neither downloaded nor activated.
- Dependency order: both dev and production activate dependencies first (
clientalways comes first).
4. Dependency declarations: deps
Closure resolution (resolve-closure.mjs) derives dependencies in two ways and takes the union:
- Source imports: scan
import ... from '@/resource/<other>/...';otheris a dependency. - Declarative deps:
clientx.depsin the resource’spackage.json(written byclientx create --deps a,b). A skeleton resource without cross-resource imports uses this to enter the closure.
node scripts/p3/resolve-closure.mjs --resources userx # inspect the closure and externals
Example output (excerpt):
{ "closure": [ { "id": "userx", "entry": "src/resource/userx/register.ts", "deps": ["client"], "externals": [...] } ] }
The deps in the built closure are resource ids (resolved at build time); app/user in Resource.dependsOn are kind-level constraints (the resource of that kind must exist before this one). Do not confuse them. In practice the built closure for userx is ["client"].
Dependency rules:
- A dependency must be a known resource (
src/resource/<id>/register.tsexists).createvalidates this. - The closure is transitive: if
A → B → C, declaringApulls inBandC. - Each dependency produces a rollup external with the
@/resource/<dep>/prefix; the importmap resolves it precisely at runtime.
5. Custom blocks (site resources)
site resources (such as portal) render pages through the sitex block registry. Built-in blocks include text / cards / feature / stats / cta / news / logos. Custom blocks are registered in activate:
// resource/portal/register.ts
import { registerBlock } from '@/resource/sitex/blocks'
export async function activatePortal(): Promise<void> {
const { DownloadCard } = await import('./components/download-card')
const { PortalChangelog } = await import('./components/portal-changelog')
registerBlock('download', DownloadCard) // custom block: download card
registerBlock('portal-changelog', PortalChangelog) // custom block: changelog
}
Blocks are pure data + renderer: content contains only { type, ...props }, and rendering looks up the block registry by type. Independent documentation sites (docs / help) are built as Astro static resources and do not enter the runtime resource closure.
6. i18n
Each resource ships i18n/zh.json and i18n/en.json with keys namespaced under the resource (portal.nav.product, etc.). During declare/activate, the resource registry (loadEnabledResourceLocales / the loader’s loadResourceLocales) injects locales for enabled resources; disabled resources do not download their locale bundles.
{ "portal": { "nav": { "product": "Product & features", "docs": "Docs & development" } } }
Use useTranslation() and t() in components. Site chrome titleKey / labelKey / bodyKey all go through i18n keys. Note: the documentation site now has separate Chinese and English HTML pages; the English pages live under /docs/en/.
7. Next steps
- Package guide: how
clientx buildturns a resource closure intodist/. - Case study: the full source of a minimal
helloresource.