55 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.
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 |
|---|---|---|
@<scope> |
npm scope of both packages | decide |
<gitea-host> |
Hostname of the Gitea instance hosting the source repository and the package registry | decide |
<owner> |
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:
licenseis"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.nodeis">=22.12"in every manifest, derived from the strictest installed dependency (better-sqlite313 requires Node 22; Vite 8 requires 20.19 or 22.12). The committed.npmrcalready setsengine-strict=true, which is inert until anenginesfield 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:
@<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.
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-basedcreateRealtimeRuntimefactory 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/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.
- Do not switch
server.jsto TypeScript under Node's type stripping. The production entrypoint stays plain JavaScript (see "Production server compile").
Preconditions for the implementation session
- Read
AGENTS.mdand the relevant subsystem documentation completely. - Inspect
git statusbefore editing. Preserve any unrelated user changes and do not fold them into migration commits. (When this revision was written the worktree was clean.) - 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-checkzero errors, the node Vitest project passing.) - Confirm the "Decisions required before handoff" table is filled in, and replace every
@<scope>,<gitea-host>, and<owner>placeholder below with those values. The npm@<scope>string and the Gitea<owner>path segment are independent and do not have to match, but keeping them identical avoids confusion. - 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.
- Confirm the Gitea instance has Git LFS enabled (
[server] LFS_START_SERVER = truein itsapp.ini); otherwise the LFS stage cannot be pushed. - 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 fetchactions/checkoutandactions/setup-node. Without this the CI workflows can be written but not verified, and the migration cannot be declared complete.
Target repository structure
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:
apps/template ──────────────▶ @<scope>/webavatar-core
│ ▲
└────────▶ @<scope>/webavatar-humanoid
│
@<scope>/webavatar-humanoid ─────────────┘
contracts only, `import type` only
Rules:
- Core imports neither humanoid nor app/project code.
- 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. - 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 (@typescript-eslint/no-restricted-imports, with
allowTypeImports: true on the core package for files under packages/humanoid). 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 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:
{
"name": "@<scope>/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://<gitea-host>/api/packages/<owner>/npm/"
}
}
Notes on that manifest:
- The
@types/*packages are regular dependencies, not devDependencies. The published declarations reference third-party types transitively:session.tstypes the browser socket withws'sWebSocket, andanalytics.ts'srawDbexposes better-sqlite3'sDatabase. A consumer without those type packages seesanythere underskipLibCheck, silently. Shipping the types as dependencies is the non-invasive fix; hiding the leaks from the public surface is a later API change.@types/nodestays a devDependency — every consumer is a Node app and already has it. - Vite is an optional peer. Only the
./viteentry needs it, and consumers that never import that entry must not get a peer warning. - Condition order matters.
typesfirst, thenbrowser, thenimport.
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
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:
-
A
browserexport condition in the package (protects every consumer). Vite's client environment resolves thebrowsercondition; its SSR environment and plain Node do not. The./serverand./viteentries therefore map, for browser bundles only, to a stub that throws when evaluated:// packages/core/src/server/browser-stub.ts throw new Error( '@<scope>/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.
-
A server-only wrapper in the app, so SvelteKit's own guard covers all app code that goes through it:
// apps/template/src/lib/server/webavatar-core.ts export * from '@<scope>/webavatar-core/server';SvelteKit routes and server modules import through this wrapper. Root-only entrypoints such as
server.jsandvite.config.tsmay import the package server and Vite entries directly. -
A lint rule forbidding
@<scope>/webavatar-core/serverand@<scope>/webavatar-core/viteeverywhere in the app exceptsrc/lib/server/**,server.js, andvite.config.ts. -
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:
import type { Plugin } from 'vite'; const SERVER_ONLY = /^@<scope>\/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:
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:
import { createServer } from 'node:http';
import { createRealtimeRuntime } from '@<scope>/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 @<scope>/webavatar-devtools-svelte package as a follow-up.
Package 2: @<scope>/webavatar-humanoid
Responsibilities
Move these paths into the package:
src/lib/humanoid/**(includingeye/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.
{
"name": "@<scope>/webavatar-humanoid",
"version": "0.1.0",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=22.12" },
"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/"
}
}
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.
import type { Logger } from '@<scope>/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<EyeConfig> = {},
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(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.
Working directory, environment files, and ignores
.env,.env.example, and thedata/analytics directory live underapps/template. The Vite dev plugin loads.envfrom the current working directory, andconfig.tsdefaults the analytics database path from it, so app scripts must run with the app as their working directory: eithernpm run <script> --workspace apps/templatefrom the root (npm sets the working directory to the workspace) or from inside the directory.- The root
.gitignorecurrently anchors/build,/build-server,/data, and/.svelte-kitat the repository root. Replace them withapps/*/build,apps/*/build-server,apps/*/data, andapps/*/.svelte-kit, and addpackages/*/dist..prettierignore's/static/becomesapps/*/static/.eslint.config.jskeeps reading.gitignoreviaincludeIgnoreFile. .claude/launch.jsonstarts the dev server from the app:"runtimeArgs": ["run", "dev", "--workspace", "apps/template"]; the port stays5173.playwright.config.tsmoves into the app. No end-to-end tests exist yet; keep the script.
TypeScript configuration
The app's tsconfig.json has to extend two files — the shared base and SvelteKit's generated
config. Use the array form, and list the generated config last: later entries override earlier
ones, and the generated config must stay authoritative for paths, rootDirs, and include.
{
"extends": ["../../tsconfig.base.json", "./.svelte-kit/tsconfig.json"],
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"sourceMap": true,
"moduleResolution": "bundler"
}
}
Production server compile
server.js is a plain-JavaScript Node entrypoint and it needs two kinds of TypeScript: the core
server code, which after the migration it imports from @<scope>/webavatar-core/server, and the
project persona and tool registry, which stay TypeScript inside the app. The second kind still has
to be compiled, so the standalone compile shrinks; it does not disappear. Keep
apps/template/tsconfig.server.json, scoped to exactly the project-owned directories:
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"outDir": "build-server",
"rootDir": "src/lib",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"declaration": false,
"types": ["node"]
},
"include": ["src/lib/project/**/*.ts", "src/lib/server/project/**/*.ts"],
"exclude": ["**/*.spec.ts"]
}
server.js then imports core by package name and project code from build-server/, and is
otherwise little more than the factory call shown under "Composition-root factory". Do not remove
this compile step. Making server.js a TypeScript file run under Node's type stripping would remove
it, but that changes the deployment contract and the project files' import extensions, and is out of
scope (see "Non-goals"). Confirm that adapter-node deployment still includes the runtime package
dependencies.
Artifact parity: externalize the packages in the SSR build
Verified against the installed Vite (8.2.2): for SSR, Vite externalizes a dependency only if it
resolves inside node_modules. A workspace package is a symlink whose real path is
packages/core/..., so in the monorepo Vite bundles the core server code into the SvelteKit
server build, while a client project that installed the same package from the registry gets it
externalized and loaded from node_modules at runtime. Left alone, the template and every client
project would run different artifacts, which breaks decision 7 below.
Fix it in the template's vite.config.ts by externalizing both packages for the production build
only:
export default defineConfig(({ command }) => ({
plugins: [/* sveltekit, realtimeDevWsPlugin, serverOnlyGuard */],
ssr:
command === 'build'
? { external: ['@<scope>/webavatar-core', '@<scope>/webavatar-humanoid'] }
: undefined
// ...optimizeDeps and test config as today
}));
Deliberately not applied in dev: an externalized package is loaded through Node's module cache,
which would lose hot reloading of package changes for SSR routes during development.
Expected dev-mode reload behavior
The existing "server core is not part of HMR" gotcha survives the migration in a narrower form.
With npm run dev running and the package watchers rebuilding dist/:
| Path | After a package rebuild |
|---|---|
Browser code importing /client or humanoid |
Hot reload. Linked packages are transformed and watched by Vite like source. |
SSR route code importing through $lib/server/webavatar-core.ts |
Hot reload, for the same reason. |
The realtime WebSocket relay wired by the ./vite plugin |
Restart required. Vite's config loader externalizes every bare import, so vite.config.ts's import of the package is loaded by Node once and cached for the process. |
Verify this table during stage 6, then document the verified behavior in AGENTS.md and
docs/REALTIME_SESSION.md in place of the old warning — do not carry the old text forward untested.
Workspace and build design
The root package is never publishable:
{
"name": "webavatar-monorepo",
"private": true,
"license": "UNLICENSED",
"engines": { "node": ">=22.12" },
"workspaces": ["packages/*", "apps/*"],
"scripts": {
"build:packages": "npm run build -w packages/core && npm run build -w packages/humanoid",
"build": "npm run build:packages && npm run build -w apps/template",
"dev": "npm run build:packages && concurrently \"npm run watch -w packages/core\" \"npm run watch -w packages/humanoid\" \"npm run dev -w apps/template\"",
"check": "npm run build:packages && npm run check --workspaces --if-present",
"test": "npm run build:packages && npm run test -w packages/core -w packages/humanoid && npm run test:unit -w apps/template -- --run",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
}
}
apps/template/package.json is also "private": true, and it must declare every dependency and
devDependency it uses itself rather than relying on hoisting from the root or the packages — the
out-of-workspace template check under "Verification requirements" is what catches omissions.
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. A fresh clone must build the packages before the app can type-check, test, or run — the root scripts above do that unconditionally so it cannot be forgotten.
Both reusable packages ship compiled ESM JavaScript plus declarations under dist/, from two
tsconfigs each: tsconfig.json for checking (includes the spec files, noEmit) and
tsconfig.build.json for emitting (extends the first, excludes src/**/*.spec.ts, sets outDir,
rootDir, declaration, declarationMap, and sourceMap; declaration maps are what let editors
jump to the TypeScript source instead of dist/*.d.ts inside the monorepo):
{
"scripts": {
"build": "tsc -p tsconfig.build.json",
"watch": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"check": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"devDependencies": {
"typescript": "…",
"vitest": "…"
}
}
Each package has its own vitest.config.ts (node environment, include: ['src/**/*.spec.ts'],
expect.requireAssertions: true as today). There is deliberately no root-level Vitest projects
configuration: Vitest does not support nesting projects, and the app's own config already defines
two (browser and node). The root test script names each workspace's test command explicitly
instead, and for the app runs only the unit tests, because the app's own test script also runs
Playwright end-to-end tests, which do not belong in the workspace-wide gate.
Use a root tsconfig.base.json for common strictness (strict, esModuleInterop,
forceConsistentCasingInFileNames, skipLibCheck, isolatedModules, verbatimModuleSyntax) while
letting each package select the runtime libraries and module resolution it needs — the packages use
nodenext, which also validates .js import extensions the way tsconfig.server.json does today.
Do not let browser code gain Node globals merely because server code exists in another package entry
point.
Root scripts build in dependency order: core, humanoid, template app. The root dev workflow builds
the packages once, then watches core and humanoid alongside the SvelteKit dev server;
concurrently is the process runner. Package exports 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 since its exported runtime requires them. Vite is an optional peer of core for
the ./vite integration, so an unrelated Vite copy is never 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 — and note that npm
refuses to run at all when an .npmrc references an environment variable that is unset, so a
committed ${GITEA_NPM_TOKEN} line would break npm install for everyone without the variable.
The owner must be a private organization. Gitea serves packages with the visibility of their owner: a package under a public user or a public organization is anonymously downloadable on any reachable instance, which would violate "do not publish anything publicly". After the first prerelease publish, fetch the package metadata URL without credentials and confirm the request is refused.
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.
Versions, ranges, and the 0.x window
Version core and humanoid independently, both starting at 0.1.0 unless existing internal releases
require another baseline. Use caret ranges everywhere — humanoid's peer and dev ranges on core, and
the template's ranges on both — and do not widen them to span minors. Humanoid's runtime reads
protocol event names as string literals, so a core release that changed those contracts would break
humanoid silently under a wide range; caret semantics are what make npm report the conflict instead.
Under semver a caret range on 0.x admits only patch releases, so during the 0.x window every
core minor is a coordinated release: bump core's version, and in the same commit bump humanoid's peer
and dev ranges to the new minor (or an || union of the minors humanoid was actually tested
against), bump the template's two ranges, and release humanoid even if nothing else in it changed.
That churn is the cost of 0.x, so keep the window short: once the verification list passes end to
end and the first prerelease has been consumed from the registry, release 1.0.0 of both packages.
From then on a core minor needs nothing from its dependents, and a contract change is a major, which
correctly forces a humanoid release.
Hard rule, checked by CI: every intra-workspace range must be satisfied by the workspace versions in
every commit. A range that does not resolve locally makes npm fetch from the registry instead, which
needs credentials and silently replaces the workspace link with a downloaded copy. The CI verify
job therefore runs npm ci with no registry token, so any unresolved range fails the build.
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.
Continuous integration
No CI exists today, so the boundary lint rules and the "green from a clean install" gate are only
enforced on whichever machine remembers to run them. Add two Gitea Actions workflows as part of the
migration. Gitea Actions uses GitHub Actions syntax, and the runner must be able to fetch
actions/checkout and actions/setup-node (precondition 7).
verify runs on every push and pull request, deliberately without a registry token: with the
committed .npmrc scope mapping in place, any @<scope> range that does not resolve to a workspace
makes npm ci contact the registry, fail to authenticate, and fail the job — which is exactly the
workspace-range rule above, enforced mechanically.
# .gitea/workflows/verify.yml
name: verify
on:
push:
pull_request:
jobs:
verify:
# A runner (or container image) with Node 22+, git-lfs, and Chromium's system
# dependencies for the browser-mode Vitest project.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci # no registry token on purpose — see "Versions, ranges, and the 0.x window"
- run: npx playwright install --with-deps chromium
- run: npm run lint
- run: npm run check
- run: npm test
- run: npm run build
- run: npm publish --dry-run -w packages/core -w packages/humanoid
- name: Humanoid ships no runtime reference to core
run: if grep -rl "webavatar-core" packages/humanoid/dist --include="*.js"; then exit 1; fi
- name: The SvelteKit server build imports core by name
run: grep -rl "@<scope>/webavatar-core/server" apps/template/build/server
publish runs only for the release tags from "Private publication and versioning", publishes only
the workspace the tag names, and refuses a tag whose version does not match that workspace's
manifest. The token is a repository or organization Actions secret with package write permission,
named NPM_PUBLISH_TOKEN (Gitea, like GitHub, rejects secret names that start with GITEA_ or
GITHUB_; the GITEA_NPM_TOKEN name above is for developers' local environments only).
# .gitea/workflows/publish.yml
name: publish
on:
push:
tags: ['core-v*', 'humanoid-v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://<gitea-host>/api/packages/<owner>/npm/
scope: '@<scope>'
- run: npm ci
- run: npm run build:packages
- name: Resolve the workspace from the tag and check its version
run: |
case "$GITHUB_REF_NAME" in
core-v*) WS=packages/core; VERSION="${GITHUB_REF_NAME#core-v}" ;;
humanoid-v*) WS=packages/humanoid; VERSION="${GITHUB_REF_NAME#humanoid-v}" ;;
*) echo "unexpected tag $GITHUB_REF_NAME"; exit 1 ;;
esac
test "$(node -p "require('./$WS/package.json').version")" = "$VERSION"
echo "WS=$WS" >> "$GITHUB_ENV"
- run: npm test -w "$WS"
- run: npm publish -w "$WS"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
Publishing stays deliberate: a person pushes the tag after the release steps above, and the workflow
is what holds the token. verify must be green on the migration branch before the first tag is
pushed.
Starting a client project from the template
The packages are what clients upgrade; the template is what they start from. Document this
procedure in apps/template/README.md and keep it working:
- Copy
apps/templateinto a new repository. Do not copy the monorepo root,packages/, or the root lockfile. - The copied
package.jsonbecomes the project's root manifest. It stays"private": true. Its@<scope>/*ranges no longer resolve to workspace links, so pin them to published versions. - Add the committed
.npmrcscope mapping, configure registry authentication as above, and runnpm install. - Copy
.env.exampleto.env, fill in the secrets, and runnpm run dev. The demo routes stay dev-only gated and can be kept or deleted. - Project code goes under
src/lib/**/project, exactly as today. - To upgrade, bump the package versions; a peer-range conflict between core and humanoid surfaces as an npm install error, which is the intended signal.
Changes to the template shell itself (server.js, vite.config.ts, routes, deployment files) do
not arrive through package upgrades; a client applies them by hand. Keep the shell thin so that
stays rare: the composition-root factory is what keeps server.js and the Vite wiring thin, so a
change in how the relay is assembled ships inside a core release, and the shell only changes when
the factory's options change.
Migration sequence
Implement in small, reviewable stages while keeping behavior stable:
0. Move the avatar model to Git LFS
static/models/avatar.glb is 14 MB and is nearly the entire .git history. This rewrites history,
so it happens first, before the repository is pushed or cloned anywhere else (preconditions 5 and 6),
and it is the only irreversible stage.
git lfs install
git lfs migrate import --include="*.glb" --everything
git lfs ls-files # must list static/models/avatar.glb
cat .gitattributes # migrate writes: *.glb filter=lfs diff=lfs merge=lfs -text
The *.glb pattern covers every current and future model, not just the demo avatar. If migrate
did not leave .gitattributes committed, commit it. Any pre-existing local clone must be re-cloned
afterward. The later move of the file to apps/template/static/ is then a pointer move.
1. Establish the workspace shell
- Add the private root workspace manifest, root scripts, and
tsconfig.base.json. - Create the package and app directories and manifests.
- Update
.gitignore,.prettierignore, and.claude/launch.jsonas described under "Working directory, environment files, and ignores". - Keep root linting, formatting, and documentation coverage.
2. Move the SvelteKit app under apps/template
- A pure move (
git mv) of the app:src/,static/,server.js,vite.config.ts,tsconfig.json,tsconfig.server.json,playwright.config.ts,.env.example, and the local.envanddata/. - Keep every
$libimport as it is. Nothing is extracted yet; the point of this stage is that the root stops doing two jobs before any package exists. - Switch the app's
tsconfig.jsonto the array-formextends. - Verify
dev,check, both Vitest projects,build, andnode server.jsfrom the app directory before continuing.
3. 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.
4. Extract core client, server, and Vite entry points
- Move implementation files without behavioral rewrites.
- Split public entry points by runtime; add the
browserstub. - Add
createRealtimeRuntimeto the./serverentry as a verbatim move of the composition code, and make the./viteplugin call it. - Move the debug-panel Svelte UI and its browser-mode test into the app.
- Move
@types/wsand@types/better-sqlite3to core's dependencies. - Make the package build, check, test, and pack independently.
5. Extract humanoid
- Move pure and client humanoid code, including
eye/DESIGN_NOTES.md. - Replace the protocol import with a type-only import of the core package's public contract.
- Add core as a peer/dev dependency. Add no logging.
- Keep Three.js, GLB assets, and renderer components in the app.
6. Rewire the template app
- Convert app imports to package entry points; add
src/lib/server/webavatar-core.ts. - Reduce
tsconfig.server.jsonto the project directories, and rewriteserver.jsaroundcreateRealtimeRuntime: core by package name, project code frombuild-server/. - Add
ssr.externalfor the build and theserverOnlyGuardplugin tovite.config.ts. - Update API routes, project tools, demos, and tests.
- Verify the "Expected dev-mode reload behavior" table.
7. Enforce boundaries
- Add restricted-import lint rules for core, humanoid (type-only from core), server-only, and app/project directions.
- Search for leftover
$lib/core,$lib/humanoid, cross-package relative imports, and package imports from app-owned paths. - Add the
verifyandpublishworkflows under.gitea/workflows/and getverifygreen on the branch before the distribution stage.
8. Add distribution verification
- Pack both packages.
- Inspect tarball contents.
- Install tarballs into an isolated bare consumer and into an out-of-workspace copy of the template.
- Test private-registry publication with prerelease versions, pushed as release tags through the
publishworkflow, before declaring the migration done.
9. 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, including the verified reload behavior.
- Add package-local READMEs documenting supported exports and compatibility, and the client-project procedure to the template README.
- 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, and
grep -r "webavatar-core" packages/humanoid/distmatches only.d.tsfiles — never.js. - Template
svelte-check, both Vitest projects (browser and node), and production build. npm pack --dry-runfor both packages contains only intended runtime files, declarations, package metadata, and documentation.- A bare consumer outside the workspace can install the packed core and humanoid tarballs and import every documented entry point.
- A copy of
apps/templateoutside the workspace, with the packed tarballs installed in place of the workspace links and nothing hoisted from the monorepo, passescheck, both Vitest projects, andbuild, and bootsserver.jswith the mock provider far enough to mint a token and complete a mock session. This is the proof that the template declares its own dependencies and that the registry-installed artifact path works end to end. - 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, and the SvelteKit server build imports the package by name instead of inlining it (a chunk underapps/template/build/serverreferences@<scope>/webavatar-core/server; none contains the core server source). - Server-only guard: a temporary client-side import of
@<scope>/webavatar-core/serverfails the template build; with the guard plugin disabled, the client bundle contains the browser stub's error message and none of the server code. Revert the temporary import afterward. - The "Expected dev-mode reload behavior" table has been verified on all three paths and the docs describe what was observed.
git lfs ls-fileslists the model, and a fresh clone with LFS installed receives the real file rather than a pointer.- A prerelease publish through the tag-driven
publishworkflow succeeds, and fetching the package metadata without credentials is refused. - The
verifyworkflow is green on the migration branch, includingnpm ciwith no registry credentials andnpm publish --dry-runfor both packages.
Acceptance criteria
- Core and humanoid are independently versioned installable, ESM-only 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 only through type-only imports of public contracts; its built JavaScript contains no runtime reference to core.
- The app is the only composition root that chooses concrete client/server implementations.
- Server-only imports remain mechanically protected, in every consumer, by the package's
browserstub, and in the app by the wrapper, the lint rule, and the build-time guard. - 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, and a copy of it outside the workspace works against the published packages.
- The avatar model is stored in Git LFS.
server.jsand the Vite plugin are thin composition roots that callcreateRealtimeRuntime; relay wiring lives in core and reaches clients through core releases.- CI verifies every push without registry credentials, and publishing is tag-driven with the token held only as a CI secret.
- Documentation describes the final filesystem, imports, release process, compatibility policy, the verified dev-reload behavior, and how a client project starts from the template.
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, type-only dependency: humanoid may depend on portable core contracts as types only; core never knows humanoid exists.
- Dependency injection: logger and future runtime services are passed into humanoid wrappers, not imported as ambient core singletons — and defaults such as a no-op logger live in humanoid, never imported from core.
- 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, and the SvelteKit server build externalizes the packages so the monorepo runs what clients run.
- Server code never reaches a browser bundle: the package's
browserstub guarantees it for every consumer, and the template adds the wrapper, the lint rule, and the build-time guard. - Thin composition roots:
server.jsand the Vite plugin callcreateRealtimeRuntime; how the relay is assembled is core's concern and ships in core releases. - Caret ranges, short
0.x: intra-workspace ranges always resolve locally (CI installs without credentials),0.xminors are coordinated releases, and1.0.0follows the first consumed prerelease.