You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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

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

  • @<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-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 @<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.
  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

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:

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

{
	"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.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:

    // 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.

  2. 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.js and vite.config.ts may import the package server and Vite entries directly.

  3. A lint rule forbidding @<scope>/webavatar-core/server and @<scope>/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:

    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/** (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.

{
	"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.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/** @<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 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 <script> --workspace apps/template from the root (npm sets the working directory to the workspace) or from inside the directory.
  • The root .gitignore currently anchors /build, /build-server, /data, and /.svelte-kit at the repository root. Replace them with apps/*/build, apps/*/build-server, apps/*/data, and apps/*/.svelte-kit, and add packages/*/dist. .prettierignore's /static/ becomes apps/*/static/. eslint.config.js keeps reading .gitignore via includeIgnoreFile.
  • .claude/launch.json starts the dev server from the app: "runtimeArgs": ["run", "dev", "--workspace", "apps/template"]; the port stays 5173.
  • playwright.config.ts moves 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:

  1. Update the package version and changelog.
  2. Update dependent peer ranges only if compatibility changed.
  3. Build, test, and inspect npm pack --dry-run for that workspace.
  4. Install the produced tarball into a consumer fixture outside the workspace dependency graph.
  5. Publish only the affected workspace to the private registry.
  6. 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:

  1. Copy apps/template into a new repository. Do not copy the monorepo root, packages/, or the root lockfile.
  2. The copied package.json becomes the project's root manifest. It stays "private": true. Its @<scope>/* ranges no longer resolve to workspace links, so pin them to published versions.
  3. Add the committed .npmrc scope mapping, configure registry authentication as above, and run npm install.
  4. Copy .env.example to .env, fill in the secrets, and run npm run dev. The demo routes stay dev-only gated and can be kept or deleted.
  5. Project code goes under src/lib/**/project, exactly as today.
  6. 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.json as 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 .env and data/.
  • Keep every $lib import 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.json to the array-form extends.
  • Verify dev, check, both Vitest projects, build, and node server.js from 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 browser stub.
  • Add createRealtimeRuntime to the ./server entry as a verbatim move of the composition code, and make the ./vite plugin call it.
  • Move the debug-panel Svelte UI and its browser-mode test into the app.
  • Move @types/ws and @types/better-sqlite3 to 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.json to the project directories, and rewrite server.js around createRealtimeRuntime: core by package name, project code from build-server/.
  • Add ssr.external for the build and the serverOnlyGuard plugin to vite.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 verify and publish workflows under .gitea/workflows/ and get verify green 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 publish workflow, before declaring the migration done.

9. Update documentation

  • Rewrite AGENTS.md orientation for the workspace layout.
  • Update docs/ARCHITECTURE.md to 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:

  1. Root formatting and lint checks.
  2. Core type-check, unit tests, and build.
  3. Humanoid type-check, unit tests, and build, and grep -r "webavatar-core" packages/humanoid/dist matches only .d.ts files — never .js.
  4. Template svelte-check, both Vitest projects (browser and node), and production build.
  5. npm pack --dry-run for both packages contains only intended runtime files, declarations, package metadata, and documentation.
  6. A bare consumer outside the workspace can install the packed core and humanoid tarballs and import every documented entry point.
  7. A copy of apps/template outside the workspace, with the packed tarballs installed in place of the workspace links and nothing hoisted from the monorepo, passes check, both Vitest projects, and build, and boots server.js with 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.
  8. Browser smoke test of /demo/humanoid, including avatar-state and eye-gaze behavior.
  9. Mock-provider smoke test through the template app.
  10. Actual /demo/realtime round trip against OpenAI, including a tool call, because the custom dev/production wiring and package boundary changed even if the wire protocol did not.
  11. Production server.js boots 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 under apps/template/build/server references @<scope>/webavatar-core/server; none contains the core server source).
  12. Server-only guard: a temporary client-side import of @<scope>/webavatar-core/server fails 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.
  13. The "Expected dev-mode reload behavior" table has been verified on all three paths and the docs describe what was observed.
  14. git lfs ls-files lists the model, and a fresh clone with LFS installed receives the real file rather than a pointer.
  15. A prerelease publish through the tag-driven publish workflow succeeds, and fetching the package metadata without credentials is refused.
  16. The verify workflow is green on the migration branch, including npm ci with no registry credentials and npm publish --dry-run for 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 browser stub, 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.js and the Vite plugin are thin composition roots that call createRealtimeRuntime; 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

  1. Private packages, not copied source: downstream projects install versions; they do not merge package source from the template branch.
  2. Independent versions: core and humanoid can release separately.
  3. One-way, type-only dependency: humanoid may depend on portable core contracts as types only; core never knows humanoid exists.
  4. 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.
  5. App-owned composition: project persona, tools, routes, rendering, environment policy, and deployment wiring stay in the template/project.
  6. Curated exports: the public API is smaller and more stable than the internal filesystem.
  7. 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.
  8. Server code never reaches a browser bundle: the package's browser stub guarantees it for every consumer, and the template adds the wrapper, the lint rule, and the build-time guard.
  9. Thin composition roots: server.js and the Vite plugin call createRealtimeRuntime; how the relay is assembled is core's concern and ships in core releases.
  10. Caret ranges, short 0.x: intra-workspace ranges always resolve locally (CI installs without credentials), 0.x minors are coordinated releases, and 1.0.0 follows the first consumed prerelease.