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"
  }
}

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>:

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

  1. Boot (main.tsx / core-loader) calls declare() synchronously.
  2. declare() registers identity, default resources, routes (lazy) and pure-data features/site content.
  3. Persisted overrides are applied (resources disabled by the user become absent).
  4. Activate is called only for resources that are not disabled.
  5. activate() registers capabilities, slots, providers and side-effect mounts.
  6. Dependencies are declared/activated before their dependents.

Key invariants:

4. Dependency declarations: deps

Closure resolution (resolve-closure.mjs) derives dependencies in two ways and takes the union:

  1. Source imports: scan import ... from '@/resource/<other>/...'; other is a dependency.
  2. Declarative deps: clientx.deps in the resource’s package.json (written by clientx 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:

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