# Monorepo migration implementation brief > **Status:** Proposed target architecture, not yet implemented. This document is a handoff for a > fresh implementation session. Until that migration is complete, `docs/ARCHITECTURE.md` describes > the repository's actual structure. ## Decisions required before handoff These values cannot be derived from the repository, and the implementation session must not invent them. The clone this brief was written against has **no git remote configured**, so none of them can be read from the repository or organization configuration either. | Placeholder | Meaning | Value | | -------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------- | | `@` | npm scope of both packages | _decide_ | | `` | Hostname of the Gitea instance hosting the source repository and the package registry | _decide_ | | `` | Gitea user or organization the packages publish under. Must be a **private organization** (see "Private publication" below). | _decide_ | Two related values are fixed by this brief and need no decision: - `license` is `"UNLICENSED"` in every manifest — npm's value for private, not-for-redistribution packages. No LICENSE file is required. Change this only if the organization decides on an actual license. - `engines.node` is `">=22.12"` in every manifest, derived from the strictest installed dependency (`better-sqlite3` 13 requires Node 22; Vite 8 requires 20.19 or 22.12). The committed `.npmrc` already sets `engine-strict=true`, which is inert until an `engines` field exists. If the three placeholders are still blank when the implementation session starts, it must complete every stage that does not need them — everything except publication and the registry parts of distribution verification — leave the placeholders literally in place, and stop with a clear statement of what remains blocked. It must not guess. ## Objective Convert `webavatar-core` from a SvelteKit template whose reusable modules live inside the app into an npm-workspaces monorepo with independently versioned, privately distributed packages: - `@/webavatar-core` — reusable shared, browser, server, and Vite integration. - `@/webavatar-humanoid` — optional humanoid state and animation functionality. - `apps/template` — the SvelteKit application template, demo surfaces, project-owned configuration, and composition roots. The packages must be normal npm-format packages, but they must not be published to the public npmjs.com registry. This repository is hosted on a self-hosted Gitea instance; use Gitea's built-in package registry (npm support since Gitea 1.17) rather than provisioning a separate service. A Gitea package belongs to an owner (a user or organization), not to a git repository — the owner does not need to be the same account that hosts this source repo, though keeping them aligned is simpler to reason about. A package needs no advance provisioning: Gitea creates it automatically on the first `npm publish` under that owner. Both packages are **ESM-only**: their `exports` maps carry `types`, `browser`, and `import` conditions only, no CommonJS build is produced, and `require()` of either package is unsupported by design. This migration is about packaging, ownership, and build boundaries. Preserve existing runtime behavior and wire protocols unless a change is specifically required to establish a package API. ## Why this change The current directory convention reduces conflicts between `core`, `project`, and `humanoid`, but client projects still have to merge the entire template branch to receive a reusable subsystem update. Versioned packages will let a project upgrade core and humanoid independently, receive an explicit compatibility error when versions do not match, and avoid merging unrelated routes, deployment files, demos, or project content. The monorepo retains the important development property that core, humanoid, and the reference app can be changed and tested together. ## Non-goals - Do not publish anything publicly. - Do not redesign the realtime protocol, tool execution, analytics, logging behavior, or animation algorithms as part of the move. - Do not make Three.js, the demo avatar, or any renderer part of the humanoid package. - Do not make project persona, project tools, UI, or deployment policy part of core. - Do not add a global service locator or global `configureHumanoid()` API. The instance-based `createRealtimeRuntime` factory under "Composition-root factory" is neither. - Do not add logging calls, logger parameters, or any other speculative API surface to humanoid as part of the move (see "Interaction with core services"). - Do not preserve old `$lib/core` or `$lib/humanoid` import paths with permanent compatibility aliases. Update consumers to the package API. - Do not extract every small concern into its own package during the first migration. - Do not switch `server.js` to TypeScript under Node's type stripping. The production entrypoint stays plain JavaScript (see "Production server compile"). ## Preconditions for the implementation session 1. Read `AGENTS.md` and the relevant subsystem documentation completely. 2. Inspect `git status` before editing. Preserve any unrelated user changes and do not fold them into migration commits. (When this revision was written the worktree was clean.) 3. Record a green baseline with the existing `check`, unit-test (both Vitest projects, browser and node), lint, and build commands. If the baseline is not green, distinguish pre-existing failures from migration regressions. (When this revision was written: prettier and eslint clean, `svelte-check` zero errors, the node Vitest project passing.) 4. Confirm the "Decisions required before handoff" table is filled in, and replace every `@`, ``, and `` placeholder below with those values. The npm `@` string and the Gitea `` path segment are independent and do not have to match, but keeping them identical avoids confusion. 5. Add the git remote for the Gitea repository. The LFS stage below rewrites history and must finish before the repository is pushed or cloned anywhere else. 6. Confirm the Gitea instance has Git LFS enabled (`[server] LFS_START_SERVER = true` in its `app.ini`); otherwise the LFS stage cannot be pushed. 7. Confirm Gitea Actions is enabled on the instance (`[actions] ENABLED = true`) with a registered runner that has Node 22+, git-lfs, and Chromium's system dependencies, and that the runner can fetch `actions/checkout` and `actions/setup-node`. Without this the CI workflows can be written but not verified, and the migration cannot be declared complete. ## Target repository structure ```text webavatar/ ├── .gitattributes # LFS: *.glb (see migration stage 0) ├── .gitignore # workspace-relative patterns (see "Template app") ├── .npmrc # scope → registry mapping only, never a token ├── .claude/launch.json # dev server runs from apps/template ├── .gitea/workflows/ # verify.yml (every push), publish.yml (release tags) ├── package.json # private workspace root ├── package-lock.json # the only lockfile ├── tsconfig.base.json ├── eslint.config.js ├── prettier.config.js ├── docs/ ├── packages/ │ ├── core/ │ │ ├── package.json │ │ ├── tsconfig.json # check: includes specs, noEmit │ │ ├── tsconfig.build.json # emit: excludes specs, → dist/ │ │ ├── vitest.config.ts # node environment │ │ ├── README.md │ │ └── src/ │ │ ├── index.ts │ │ ├── protocol.ts │ │ ├── logger.ts │ │ ├── tools.ts │ │ ├── persona.ts # SessionPersona contract (moved out of project code) │ │ ├── client/ │ │ │ └── index.ts │ │ ├── server/ │ │ │ ├── index.ts │ │ │ └── browser-stub.ts # what browser bundles get instead of server code │ │ └── vite/ │ │ └── index.ts │ └── humanoid/ │ ├── package.json │ ├── tsconfig.json │ ├── tsconfig.build.json │ ├── vitest.config.ts │ ├── README.md │ └── src/ │ ├── index.ts │ ├── client/ │ │ └── index.ts │ └── eye/ │ ├── index.ts │ ├── client.ts │ └── DESIGN_NOTES.md └── apps/ └── template/ ├── package.json # private; declares every dependency it uses itself ├── .env.example # .env and data/ live here too (both gitignored) ├── vite.config.ts ├── playwright.config.ts ├── server.js ├── tsconfig.json # extends base + the generated SvelteKit config ├── tsconfig.server.json # project code only → build-server/ (see below) ├── src/ │ ├── lib/project/ │ ├── lib/server/project/ │ ├── lib/server/webavatar-core.ts # server-only wrapper (see below) │ ├── lib/client/project/ │ ├── lib/client/DebugPanel.svelte # + DebugPanel.svelte.spec.ts │ └── routes/ └── static/models/avatar.glb # LFS pointer in git ``` Exact internal filenames may stay close to the current layout. The important change is that package boundaries become real `package.json` and `exports` boundaries, not merely directory conventions. ## Ownership and dependency graph The allowed graph is: ```text apps/template ──────────────▶ @/webavatar-core │ ▲ └────────▶ @/webavatar-humanoid │ @/webavatar-humanoid ─────────────┘ contracts only, `import type` only ``` Rules: 1. Core imports neither humanoid nor app/project code. 2. Humanoid may import only public, portable core contracts, and only as types (`import type`). It must not import core server or browser implementations, and it must not import any core value. Its built JavaScript therefore contains no reference to the core package at all. 3. The template app may import public exports from both packages. 4. Cross-package imports use package names. Relative imports never cross a package boundary. 5. Neither package may use SvelteKit aliases such as `$lib`, `$app`, or `$env`. 6. Project code remains in `apps/template/src/lib/**/project` and may depend on either package. 7. Renderer and avatar-model knowledge remains in the app. Add lint rules that enforce these directions (`@typescript-eslint/no-restricted-imports`, with `allowTypeImports: true` on the core package for files under `packages/humanoid`). Documentation alone is insufficient. ## Package 1: `@/webavatar-core` ### Responsibilities Move the reusable code currently under these paths into this package: - `src/lib/core/**` - `src/lib/client/core/**`, except the Svelte debug-panel component described below - `src/lib/server/core/**` The route endpoint that mints a session token stays in the app because routing is an application concern; it imports token functionality through the app's server-only wrapper (below). Move the `SessionPersona` interface out of project code and into a portable core contract. The current type-only imports from `server/core` to `project/persona.ts` violate the intended dependency direction even though TypeScript erases them at runtime. The project should export a value that `satisfies SessionPersona`. ### Public entry points Create curated index files and export only supported APIs. The manifest: ```json { "name": "@/webavatar-core", "version": "0.1.0", "license": "UNLICENSED", "type": "module", "engines": { "node": ">=22.12" }, "files": ["dist"], "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./client": { "types": "./dist/client/index.d.ts", "import": "./dist/client/index.js" }, "./server": { "types": "./dist/server/index.d.ts", "browser": "./dist/server/browser-stub.js", "import": "./dist/server/index.js" }, "./vite": { "types": "./dist/vite/index.d.ts", "browser": "./dist/server/browser-stub.js", "import": "./dist/vite/index.js" } }, "dependencies": { "better-sqlite3": "^13.0.3", "ws": "^8.21.3", "@types/better-sqlite3": "^9.6.0", "@types/ws": "^8.18.1" }, "peerDependencies": { "vite": "^8.0.0" }, "peerDependenciesMeta": { "vite": { "optional": true } }, "publishConfig": { "registry": "https:///api/packages//npm/" } } ``` Notes on that manifest: - **The `@types/*` packages are regular dependencies, not devDependencies.** The published declarations reference third-party types transitively: `session.ts` types the browser socket with `ws`'s `WebSocket`, and `analytics.ts`'s `rawDb` exposes better-sqlite3's `Database`. A consumer without those type packages sees `any` there under `skipLibCheck`, silently. Shipping the types as dependencies is the non-invasive fix; hiding the leaks from the public surface is a later API change. `@types/node` stays a devDependency — every consumer is a Node app and already has it. - **Vite is an optional peer.** Only the `./vite` entry needs it, and consumers that never import that entry must not get a peer warning. - **Condition order matters.** `types` first, then `browser`, then `import`. The root entry point is portable and contains contracts such as: - Protocol events and session types - Tool definitions - `Logger`, `LogEntry`, `LogLevel`, and `NOOP_LOGGER` - `SessionPersona` The `./client` entry point contains browser transports, resilience, client logging, tool dispatch, and server-log forwarding. The `./server` entry point contains Node-only implementations. The `./vite` entry point contains the Vite dev-server integration so importing the root does not load Vite or server code. Do not export every internal file as a wildcard. The `exports` map is the compatibility boundary that future semantic versions will protect. ### Server-only safety Today SvelteKit refuses, at build time, to let browser code import anything under `$lib/server`. That guard is path-based and does not reach `node_modules`, so once the server code is a package the protection has to be rebuilt in layers. All four are required: 1. **A `browser` export condition in the package (protects every consumer).** Vite's client environment resolves the `browser` condition; its SSR environment and plain Node do not. The `./server` and `./vite` entries therefore map, for browser bundles only, to a stub that throws when evaluated: ```ts // packages/core/src/server/browser-stub.ts throw new Error( '@/webavatar-core/server is server-only and was imported into browser code.' ); ``` The point is not the error message — it is that a client bundle can never contain the server code, in any consumer, without that consumer configuring anything. 2. **A server-only wrapper in the app**, so SvelteKit's own guard covers all app code that goes through it: ```ts // apps/template/src/lib/server/webavatar-core.ts export * from '@/webavatar-core/server'; ``` SvelteKit routes and server modules import through this wrapper. Root-only entrypoints such as `server.js` and `vite.config.ts` may import the package server and Vite entries directly. 3. **A lint rule** forbidding `@/webavatar-core/server` and `@/webavatar-core/vite` everywhere in the app except `src/lib/server/**`, `server.js`, and `vite.config.ts`. 4. **A build-time guard in the template's Vite config**, so a direct import fails the build rather than only failing in the browser at runtime: ```ts import type { Plugin } from 'vite'; const SERVER_ONLY = /^@\/webavatar-core\/(server|vite)(\/|$)/; export function serverOnlyGuard(): Plugin { return { name: 'webavatar-server-only-guard', enforce: 'pre', resolveId(id, importer, options) { const isClient = this.environment ? this.environment.name === 'client' : !options?.ssr; if (isClient && SERVER_ONLY.test(id)) { throw new Error( `${id} is server-only; import it through $lib/server instead (importer: ${importer}).` ); } return null; } }; } ``` Client projects receive this by copying the template. Verify it as described under "Verification requirements". ### Composition-root factory `server.js` and the `./vite` plugin currently duplicate the same wiring: load config, select the provider, choose the analytics recorder, resolve session defaults from the persona and tool registry, then hand everything to `handleRealtimeUpgrade`. After the migration `server.js` is a file every client project copies and never merges again, so a core change that needed new wiring would have to be applied by hand in every client. Collapse the duplication into one factory exported from the `./server` entry: ```ts export interface RealtimeRuntimeOptions { persona: SessionPersona; toolRegistry: ToolRegistry; /** Literal per entrypoint: dev auto-forwards debug logs, prod requires DEBUG_ACCESS_KEY. */ isDev: boolean; } export interface RealtimeRuntime { config: AppConfig; provider: RealtimeProvider; recorder: AnalyticsRecorder; defaults: SessionDefaults; /** Attach to an http.Server 'upgrade' event. A closure, so it can be passed unbound. */ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void; } export function createRealtimeRuntime(options: RealtimeRuntimeOptions): RealtimeRuntime; ``` The body is the existing composition code moved verbatim, not new behavior: `loadConfig()` (still memoized, still fail-fast), `selectProvider`, the `Sqlite`/`Noop` recorder choice, `resolveSessionDefaults` merged with the tool registry, and a `handleUpgrade` closure that supplies `clientToolTimeoutMs` and `logMessages` from config. It is not a service locator: it returns an instance, keeps no module state of its own, and is called once per process by whichever composition root is running. `server.js` shrinks to: ```js import { createServer } from 'node:http'; import { createRealtimeRuntime } from '@/webavatar-core/server'; import { handler } from './build/handler.js'; import { SESSION_PERSONA } from './build-server/project/persona.js'; import { toolRegistry } from './build-server/server/project/tools/index.js'; const runtime = createRealtimeRuntime({ persona: SESSION_PERSONA, toolRegistry, isDev: false }); const server = createServer(handler); server.on('upgrade', runtime.handleUpgrade); server.listen(runtime.config.port, () => console.log(`listening on port ${runtime.config.port}`)); ``` The `./vite` plugin's `configureServer` keeps its `VITEST` guard and its `.env` loading, then calls the same factory with `isDev: true` and attaches `runtime.handleUpgrade` to Vite's HTTP server. The rule in `docs/CONFIGURATION.md` still holds: the factory runs inside `configureServer`, below the guard, never at config-file module scope. Keep the lower-level pieces (`loadConfig`, `selectProvider`, the recorders, `resolveSessionDefaults`, `handleRealtimeUpgrade`) exported for a composition root that needs to deviate. ### Debug panel decision Keep the logger contract and client log store in core. For the first migration, move `DebugPanel.svelte` and its component test into the template app as UI that consumes the core client logger. That test is the repository's only browser-mode Vitest test, so the browser-mode Vitest project (`client` in the current `vite.config.ts`) moves to the app with it; the packages' own Vitest configs are node-only. Do not make the foundational core build responsible for packaging Svelte components during the same migration. If multiple client projects later need the panel, extract it in a separate `@/webavatar-devtools-svelte` package as a follow-up. ## Package 2: `@/webavatar-humanoid` ### Responsibilities Move these paths into the package: - `src/lib/humanoid/**` (including `eye/DESIGN_NOTES.md`) - `src/lib/client/humanoid/**` Keep these in the template app: - `src/routes/demo/humanoid/**` - `static/models/avatar.glb` - Three.js and its type dependency - Rig/morph-target binding and all rendering code The package must remain renderer-agnostic and must not acquire a Svelte dependency. Its hand-rolled stores may continue to satisfy the Svelte store contract structurally. ### Core compatibility Humanoid needs core's normalized `ServerEvent` contract — as a type. Today that is humanoid's only import from core (`avatar-state.ts` imports `type ServerEvent`), and the migration keeps it that way: **humanoid's dependency on core is type-only.** The built humanoid JavaScript contains no import of the core package; TypeScript erases `import type` statements, so at runtime humanoid is coupled to no particular installed copy of core. Core is still declared as a peer dependency, because humanoid's published declarations reference core's types and the consumer must have a compatible core installed for them to resolve. That is also what produces the explicit compatibility error the objective asks for: npm reports a peer-range conflict at install time. ```json { "name": "@/webavatar-humanoid", "version": "0.1.0", "license": "UNLICENSED", "type": "module", "engines": { "node": ">=22.12" }, "files": ["dist"], "peerDependencies": { "@/webavatar-core": "^0.1.0" }, "devDependencies": { "@/webavatar-core": "^0.1.0" }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./client": { "types": "./dist/client/index.d.ts", "import": "./dist/client/index.js" }, "./eye": { "types": "./dist/eye/index.d.ts", "import": "./dist/eye/index.js" }, "./eye/client": { "types": "./dist/eye/client.d.ts", "import": "./dist/eye/client.js" } }, "publishConfig": { "registry": "https:///api/packages//npm/" } } ``` Follow "Versions, ranges, and the `0.x` window" below: caret ranges only. During `0.x` a core minor requires a humanoid release that bumps this range; after `1.0.0` a core minor requires nothing from humanoid, and only a core major does. Enforce the type-only rule twice: the lint rule from "Ownership and dependency graph" (`allowTypeImports: true` means a value import from core is a lint error inside humanoid), and the post-build check under "Verification requirements" (the package's `dist/**/*.js` must not mention the core package name). ### Interaction with core services Humanoid may depend on core contracts, never on ambient runtime implementations, and never on core values. **The migration adds no logging to humanoid** — nothing in the package logs today, and a logger parameter nobody calls is speculative API surface. When the first log call site is added later, follow this shape: the `Logger` type comes from core as a type-only import, and the default no-op lives inside humanoid rather than being imported from core. ```ts import type { Logger } from '@/webavatar-core'; /** Local by design: importing core's NOOP_LOGGER would make this package's runtime depend on core for a four-line object. */ const NOOP_LOGGER: Logger = { debug() {}, info() {}, warn() {}, error() {} }; export interface EyeGazeControllerOptions { rng?: () => number; scheduleFrame?: (callback: (nowMs: number) => void) => () => void; logger?: Logger; } export function createEyeGazeController( overrides: DeepPartial = {}, options: EyeGazeControllerOptions = {} ) { const logger = options.logger ?? NOOP_LOGGER; // Stateful/effectful wrapper may log under `humanoid.eye`. } ``` The app supplies the implementation: ```ts import { clientLogger } from '@/webavatar-core/client'; import { createEyeGazeController } from '@/webavatar-humanoid/eye/client'; const gaze = createEyeGazeController(projectEyeConfig, { logger: clientLogger }); ``` Apply the same dependency-injection pattern to future clocks, randomness sources, metrics, or other runtime facilities. Keep reducers, curves, animation composition, and per-frame math pure; log at stateful boundaries rather than on every animation frame. If dependency threading becomes noisy later, add an instance-based `createHumanoidRuntime({ logger, ...config })` factory. Do not add mutable package-global configuration because it would make SSR, tests, multiple avatars, and concurrent configurations interfere with each other. ## Template app: `apps/template` The template owns: - SvelteKit routes and application HTML - Project persona and tools - `server.js` and `vite.config.ts` composition roots - Environment examples and deployment configuration - Demo routes and demo assets - The Three.js humanoid renderer - The debug-panel Svelte component during the first migration Replace existing internal imports as follows: | Current import family | Target import | | ------------------------- | ----------------------------------------------------- | | `$lib/core/**` | `@/webavatar-core` | | `$lib/client/core/**` | `@/webavatar-core/client` | | `$lib/server/core/**` | `$lib/server/webavatar-core` in app source | | `$lib/humanoid/**` | `@/webavatar-humanoid` or `/eye` | | `$lib/client/humanoid/**` | `@/webavatar-humanoid/client` or `/eye/client` | | `$lib/**/project/**` | Remains app-local project code | Prefer importing from curated entry points instead of recreating one export per old source file. If two old modules expose names that collide, use a deliberate subpath or rename the public API rather than leaking the old filesystem layout wholesale. ### Working directory, environment files, and ignores - `.env`, `.env.example`, and the `data/` analytics directory live under `apps/template`. The Vite dev plugin loads `.env` from the current working directory, and `config.ts` defaults the analytics database path from it, so app scripts must run with the app as their working directory: either `npm run