23 KiB
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.mddescribes the repository's actual structure.
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:
@<scope>/webavatar-core— reusable shared, browser, server, and Vite integration.@<scope>/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.
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. - Do not preserve old
$lib/coreor$lib/humanoidimport 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.
Preconditions for the implementation session
- Read
AGENTS.mdand the relevant subsystem documentation completely. - Inspect
git statusbefore editing. At the time this brief was written, the worktree contained unrelated changes indocs/ANALYTICS.md,src/lib/server/core/session.ts, andsrc/lib/server/core/session.spec.ts. Preserve all user changes and do not fold them into the migration accidentally. - Record a green baseline with the existing
check, unit-test, lint, and build commands. If the baseline is not green, distinguish pre-existing failures from migration regressions. - Decide the private package scope, replacing every
@<scope>placeholder below, and decide the Gitea host and owner (user or organization) the packages will publish under, replacing every<gitea-host>/<owner>placeholder below. The npm@<scope>string and the Gitea<owner>path segment are independent and do not have to match, but keeping them identical avoids confusion. Do not invent a scope, host, or owner if the repository or organization configuration does not reveal it.
Target repository structure
webavatar/
├── package.json
├── package-lock.json
├── tsconfig.base.json
├── eslint.config.js
├── prettier.config.js
├── docs/
├── packages/
│ ├── core/
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── README.md
│ │ └── src/
│ │ ├── index.ts
│ │ ├── protocol.ts
│ │ ├── logger.ts
│ │ ├── tools.ts
│ │ ├── client/
│ │ │ └── index.ts
│ │ ├── server/
│ │ │ └── index.ts
│ │ └── vite/
│ │ └── index.ts
│ └── humanoid/
│ ├── package.json
│ ├── tsconfig.json
│ ├── README.md
│ └── src/
│ ├── index.ts
│ ├── client/
│ │ └── index.ts
│ └── eye/
│ ├── index.ts
│ └── client.ts
└── apps/
└── template/
├── package.json
├── vite.config.ts
├── server.js
├── tsconfig.json
├── src/
│ ├── lib/project/
│ ├── lib/server/project/
│ ├── lib/client/project/
│ └── routes/
└── static/
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:
apps/template ──────────────▶ @<scope>/webavatar-core
│ ▲
└────────▶ @<scope>/webavatar-humanoid
@<scope>/webavatar-humanoid ───────────────┘
contracts only
Rules:
- Core imports neither humanoid nor app/project code.
- Humanoid may import only public, portable core contracts. It must not import core server or browser implementations.
- The template app may import public exports from both packages.
- Cross-package imports use package names. Relative imports never cross a package boundary.
- Neither package may use SvelteKit aliases such as
$lib,$app, or$env. - Project code remains in
apps/template/src/lib/**/projectand may depend on either package. - Renderer and avatar-model knowledge remains in the app.
Add lint rules that enforce these directions. Documentation alone is insufficient.
Package 1: @<scope>/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 belowsrc/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 from @<scope>/webavatar-core/server.
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. A starting manifest shape is:
{
"name": "@<scope>/webavatar-core",
"version": "0.1.0",
"type": "module",
"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",
"import": "./dist/server/index.js"
},
"./vite": {
"types": "./dist/vite/index.d.ts",
"import": "./dist/vite/index.js"
}
},
"publishConfig": {
"registry": "https://<gitea-host>/api/packages/<owner>/npm/"
}
}
The root entry point is portable and contains contracts such as:
- Protocol events and session types
- Tool definitions
Logger,LogEntry,LogLevel, andNOOP_LOGGERSessionPersona
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
Moving server code into node_modules means its path no longer automatically lives under
SvelteKit's $lib/server directory. In the template app, add a local wrapper:
// apps/template/src/lib/server/webavatar-core.ts
export * from '@<scope>/webavatar-core/server';
SvelteKit routes and server modules should normally import through this wrapper. Add lint rules that
forbid @<scope>/webavatar-core/server from client and universal app files. Root-only entrypoints
such as server.js may import the package server entry point directly.
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. 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
@<scope>/webavatar-devtools-svelte package as a follow-up.
Package 2: @<scope>/webavatar-humanoid
Responsibilities
Move these paths into the package:
src/lib/humanoid/**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 currently needs core's normalized ServerEvent contract. Declare core as a peer dependency
so the app owns the single compatible core installation:
{
"name": "@<scope>/webavatar-humanoid",
"version": "0.1.0",
"type": "module",
"files": ["dist"],
"peerDependencies": {
"@<scope>/webavatar-core": "^0.1.0"
},
"devDependencies": {
"@<scope>/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://<gitea-host>/api/packages/<owner>/npm/"
}
}
Use the broadest core peer range actually tested. A core release does not require a humanoid release when the existing peer range and APIs remain compatible.
Interaction with core services
Humanoid may depend on core contracts, not ambient runtime implementations. For logging:
import { NOOP_LOGGER, type Logger } from '@<scope>/webavatar-core';
export interface EyeGazeControllerOptions {
config?: Partial<EyeConfig>;
logger?: Logger;
}
export function createEyeGazeController(options: EyeGazeControllerOptions = {}) {
const logger = options.logger ?? NOOP_LOGGER;
// Stateful/effectful wrapper may log under `humanoid.eye`.
}
The app supplies the implementation:
import { clientLogger } from '@<scope>/webavatar-core/client';
import { createEyeGazeController } from '@<scope>/webavatar-humanoid/eye/client';
const gaze = createEyeGazeController({
config: 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.jsandvite.config.tscomposition 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/** |
@<scope>/webavatar-core |
$lib/client/core/** |
@<scope>/webavatar-core/client |
$lib/server/core/** |
$lib/server/webavatar-core in app source |
$lib/humanoid/** |
@<scope>/webavatar-humanoid or /eye |
$lib/client/humanoid/** |
@<scope>/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.
The custom production server should import built package output through package names. Once that
works, remove the app's special tsconfig.server.json/build-server compilation path if it is no
longer needed. Confirm that adapter-node deployment still includes the runtime package dependencies.
Workspace and build design
The root package is never publishable:
{
"name": "webavatar-monorepo",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}
Use ordinary semver ranges between workspaces so npm links a matching local workspace during development and published consumers see valid dependency metadata. Keep one root lockfile.
Both reusable packages should ship compiled ESM JavaScript plus declarations under dist/:
{
"scripts": {
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
}
}
Use a root tsconfig.base.json for common strictness while letting each package select the runtime
libraries it needs. Do not let browser code gain Node globals merely because server code exists in
another package entry point.
Root scripts should build in dependency order:
- Core
- Humanoid
- Template app
The root dev workflow must build packages once, then watch core and humanoid alongside the SvelteKit
dev server. A small process runner such as concurrently is acceptable. Package exports should point
to dist, not to TypeScript source that only Vite understands, because the custom production Node
server must consume the same artifact that projects install.
Keep three in the template's development dependencies. Keep ws and better-sqlite3 with the core
server package if they remain required by its exported runtime. Treat Vite as a compatible peer/dev
dependency for the ./vite integration rather than allowing an unrelated Vite copy to be pulled
silently into the app.
Private publication and versioning
Use Gitea's built-in package registry at https://<gitea-host>/api/packages/<owner>/npm/. Add this
committed scope mapping without a token:
@<scope>:registry=https://<gitea-host>/api/packages/<owner>/npm/
Authentication is a separate, uncommitted line — a Gitea personal access token (or, in CI, a repo- or
org-level token) with package permission:
//<gitea-host>/api/packages/<owner>/npm/:_authToken=${GITEA_NPM_TOKEN}
Authentication belongs in developer and CI secrets. Never commit a package token.
Gitea does not require a package to be created ahead of time — it is created automatically on the
first npm publish under that owner and appears afterward on the owner's Packages tab. Publish access
to an org-owned package is restricted to org members with admin or write access to that org, so decide
who that is before the first release. Linking a published package back to this source repository (for
discoverability in the repo's own UI) is optional, is done from the package's settings page after
publishing, and has no effect on npm install/npm ci.
Version core and humanoid independently. Start both at 0.1.0 unless existing internal releases
require another baseline. During 0.x development, document that minor releases may be breaking;
after 1.0.0, use normal semantic-versioning expectations.
Suggested tags:
core-v0.1.0
humanoid-v0.1.0
For each release:
- Update the package version and changelog.
- Update dependent peer ranges only if compatibility changed.
- Build, test, and inspect
npm pack --dry-runfor that workspace. - Install the produced tarball into a consumer fixture outside the workspace dependency graph.
- Publish only the affected workspace to the private registry.
- Create the corresponding immutable Git tag and release notes.
Manual versioning is sufficient for the first migration. Introduce Changesets or another release orchestrator only after the package boundaries and first private release work end to end.
Migration sequence
Implement in small, reviewable stages while keeping behavior stable:
1. Establish the workspace shell
- Add the private root workspace manifest and shared TypeScript configuration.
- Create package/app directories and manifests.
- Keep root linting, formatting, and documentation coverage.
2. Extract portable core contracts
- Move protocol, logger, tools, and persona contracts first.
- Add the core root index and export map.
- Update imports and tests.
- Eliminate every core-to-project import, including type-only imports.
3. Extract core client, server, and Vite entry points
- Move implementation files without behavioral rewrites.
- Split public entry points by runtime.
- Move the debug-panel Svelte UI into the app.
- Make the package build, test, and pack independently.
4. Extract humanoid
- Move pure and client humanoid code.
- Replace the protocol import with the core package's public contract.
- Add core as a peer/dev dependency.
- Add logger injection only at effectful wrappers; use
NOOP_LOGGERby default. - Keep Three.js, GLB assets, and renderer components in the app.
5. Move and rewire the template app
- Move the SvelteKit shell under
apps/template. - Convert app imports to package entry points.
- Update
server.js,vite.config.ts, API routes, project tools, demos, and tests. - Remove obsolete standalone compilation plumbing only after package imports work in production.
6. Enforce boundaries
- Add restricted-import lint rules for core, humanoid, server-only, and app/project directions.
- Search for leftover
$lib/core,$lib/humanoid, cross-package relative imports, and package imports from app-owned paths.
7. Add distribution verification
- Pack both packages.
- Inspect tarball contents.
- Install tarballs into an isolated consumer.
- Test private-registry publication with prerelease versions before declaring the migration done.
8. Update documentation
- Rewrite
AGENTS.mdorientation for the workspace layout. - Update
docs/ARCHITECTURE.mdto describe actual package boundaries and link this document as the completed migration rationale or replace it with a permanent packaging document. - Update subsystem docs and all source links/import examples.
- Add package-local READMEs documenting supported exports and compatibility.
- Remove the proposal warning at the top of this document only when the implementation is complete.
Verification requirements
The migration is complete only when all of the following pass from a clean install:
- Root formatting and lint checks.
- Core type-check, unit tests, and build.
- Humanoid type-check, unit tests, and build.
- Template
svelte-check, unit tests, and production build. npm pack --dry-runfor both packages contains only intended runtime files, declarations, package metadata, and documentation.- A consumer outside the workspace can install the packed core and humanoid tarballs and import every documented entry point.
- Browser smoke test of
/demo/humanoid, including avatar-state and eye-gaze behavior. - Mock-provider smoke test through the template app.
- Actual
/demo/realtimeround trip against OpenAI, including a tool call, because the custom dev/production wiring and package boundary changed even if the wire protocol did not. - Production
server.jsboots from built package artifacts rather than workspace source files.
Remember the existing server-core HMR limitation while the old layout exists. After extraction, document the new package-watch behavior accurately rather than carrying that warning forward without re-testing it.
Acceptance criteria
- Core and humanoid are independently versioned installable artifacts.
- The root and template app are private and cannot be published accidentally.
- No package imports app/project code.
- Core does not import humanoid.
- Humanoid reaches core facilities through public contracts and explicit dependency injection.
- The app is the only composition root that chooses concrete client/server implementations.
- Server-only imports remain mechanically protected.
- Pure humanoid animation functions remain pure.
- The package tarballs do not contain demos, GLB assets, project persona/tools, database files, or unrelated repository content.
- The template behaves the same in development and production as it did before the migration.
- Documentation describes the final filesystem, imports, release process, and compatibility policy.
Decisions that must survive implementation
- Private packages, not copied source: downstream projects install versions; they do not merge package source from the template branch.
- Independent versions: core and humanoid can release separately.
- One-way dependency: humanoid may depend on portable core contracts; core never knows humanoid exists.
- Dependency injection: logger and future runtime services are passed into humanoid wrappers, not imported as ambient core singletons.
- App-owned composition: project persona, tools, routes, rendering, environment policy, and deployment wiring stay in the template/project.
- Curated exports: the public API is smaller and more stable than the internal filesystem.
- Artifact parity: local development, packed consumers, and the production server use the same compiled package outputs.