From 9a911a2ff7c92262766b8932d8adfce2f94bef84 Mon Sep 17 00:00:00 2001 From: Jonathan Hinkel Date: Wed, 2 Sep 2026 17:41:18 +0200 Subject: [PATCH] Revise the monorepo migration brief after review Fill the gaps that would have stalled or misled an implementation session, and fold in the review's design changes: - Front-load the decisions only the owner can make (npm scope, Gitea host, private owner); fix license as UNLICENSED and Node at >=22.12 - Keep tsconfig.server.json, scoped to project code, instead of promising to remove the standalone compile - Externalize both packages in the SSR production build so the monorepo runs the same artifact clients install; document the expected dev-reload behavior per path - Rebuild server-only protection in layers: package-level browser export stub, app wrapper, lint rule, build-time Vite guard - Ship @types/ws and @types/better-sqlite3 as core dependencies - Make humanoid's dependency on core type-only; add no logging to it - Add createRealtimeRuntime so server.js and the Vite plugin become thin composition roots and wiring changes ship in core releases - Caret ranges with a short 0.x window; intra-workspace ranges must resolve locally, enforced by CI installing without credentials - Add Gitea Actions verify and tag-driven publish workflows - Move the SvelteKit shell first, then extract; put the avatar model in Git LFS before the first push - Describe how a client project starts from the template, and verify an out-of-workspace copy against the packed tarballs - Smaller corrections: env/data placement, ignore patterns, launch config, array-form tsconfig extends, per-package check/build tsconfigs and vitest configs, ESM-only statement Co-Authored-By: Claude Fable 5.1 --- docs/MONOREPO_MIGRATION_BRIEF.md | 829 ++++++++++++++++++++++++++----- 1 file changed, 697 insertions(+), 132 deletions(-) diff --git a/docs/MONOREPO_MIGRATION_BRIEF.md b/docs/MONOREPO_MIGRATION_BRIEF.md index f232fdf..ed4e65b 100644 --- a/docs/MONOREPO_MIGRATION_BRIEF.md +++ b/docs/MONOREPO_MIGRATION_BRIEF.md @@ -4,6 +4,32 @@ > fresh implementation session. Until that migration is complete, `docs/ARCHITECTURE.md` describes > the repository's actual structure. +## Decisions required before handoff + +These values cannot be derived from the repository, and the implementation session must not invent +them. The clone this brief was written against has **no git remote configured**, so none of them can +be read from the repository or organization configuration either. + +| Placeholder | Meaning | Value | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------- | +| `@` | npm scope of both packages | _decide_ | +| `` | Hostname of the Gitea instance hosting the source repository and the package registry | _decide_ | +| `` | Gitea user or organization the packages publish under. Must be a **private organization** (see "Private publication" below). | _decide_ | + +Two related values are fixed by this brief and need no decision: + +- `license` is `"UNLICENSED"` in every manifest — npm's value for private, not-for-redistribution + packages. No LICENSE file is required. Change this only if the organization decides on an actual + license. +- `engines.node` is `">=22.12"` in every manifest, derived from the strictest installed dependency + (`better-sqlite3` 13 requires Node 22; Vite 8 requires 20.19 or 22.12). The committed `.npmrc` + already sets `engine-strict=true`, which is inert until an `engines` field exists. + +If the three placeholders are still blank when the implementation session starts, it must complete +every stage that does not need them — everything except publication and the registry parts of +distribution verification — leave the placeholders literally in place, and stop with a clear +statement of what remains blocked. It must not guess. + ## Objective Convert `webavatar-core` from a SvelteKit template whose reusable modules live inside the app into an @@ -22,6 +48,10 @@ need to be the same account that hosts this source repo, though keeping them ali 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. @@ -43,33 +73,49 @@ can be changed and tested together. algorithms as part of the move. - Do not make Three.js, the demo avatar, or any renderer part of the humanoid package. - Do not make project persona, project tools, UI, or deployment policy part of core. -- Do not add a global service locator or global `configureHumanoid()` API. +- Do not 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. At the time this brief was written, the worktree contained - unrelated changes in `docs/ANALYTICS.md`, `src/lib/server/core/session.ts`, and - `src/lib/server/core/session.spec.ts`. Preserve all user changes and do not fold them into the - migration accidentally. -3. Record a green baseline with the existing `check`, unit-test, lint, and build commands. If the - baseline is not green, distinguish pre-existing failures from migration regressions. -4. Decide the private package scope, replacing every `@` placeholder below, and decide the - Gitea host and owner (user or organization) the packages will publish under, replacing every - ``/`` placeholder below. The npm `@` string and the Gitea `` path - segment are independent and do not have to match, but keeping them identical avoids confusion. Do - not invent a scope, host, or owner if the repository or organization configuration does not reveal - it. +2. Inspect `git status` before editing. Preserve any unrelated user changes and do not fold them into + migration commits. (When this revision was written the worktree was clean.) +3. Record a green baseline with the existing `check`, unit-test (both Vitest projects, browser and + node), lint, and build commands. If the baseline is not green, distinguish pre-existing failures + from migration regressions. (When this revision was written: prettier and eslint clean, + `svelte-check` zero errors, the node Vitest project passing.) +4. Confirm the "Decisions required before handoff" table is filled in, and replace every `@`, + ``, and `` placeholder below with those values. The npm `@` string and + the Gitea `` path segment are independent and do not have to match, but keeping them + identical avoids confusion. +5. Add the git remote for the Gitea repository. The LFS stage below rewrites history and must finish + before the repository is pushed or cloned anywhere else. +6. Confirm the Gitea instance has Git LFS enabled (`[server] LFS_START_SERVER = true` in its + `app.ini`); otherwise the LFS stage cannot be pushed. +7. Confirm Gitea Actions is enabled on the instance (`[actions] ENABLED = true`) with a registered + runner that has Node 22+, git-lfs, and Chromium's system dependencies, and that the runner can + fetch `actions/checkout` and `actions/setup-node`. Without this the CI workflows can be written + but not verified, and the migration cannot be declared complete. ## Target repository structure ```text webavatar/ -├── package.json -├── package-lock.json +├── .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 @@ -77,22 +123,28 @@ webavatar/ ├── packages/ │ ├── core/ │ │ ├── package.json -│ │ ├── tsconfig.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 +│ │ │ ├── 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 @@ -100,19 +152,25 @@ webavatar/ │ │ └── index.ts │ └── eye/ │ ├── index.ts -│ └── client.ts +│ ├── client.ts +│ └── DESIGN_NOTES.md └── apps/ └── template/ - ├── package.json + ├── 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 + ├── 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/ + └── static/models/avatar.glb # LFS pointer in git ``` Exact internal filenames may stay close to the current layout. The important change is that package @@ -126,23 +184,26 @@ The allowed graph is: apps/template ──────────────▶ @/webavatar-core │ ▲ └────────▶ @/webavatar-humanoid - -@/webavatar-humanoid ───────────────┘ - contracts only + │ +@/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. It must not import core server or - browser implementations. +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. Documentation alone is insufficient. +Add lint rules that enforce these directions (`@typescript-eslint/no-restricted-imports`, with +`allowTypeImports: true` on the core package for files under `packages/humanoid`). Documentation +alone is insufficient. ## Package 1: `@/webavatar-core` @@ -155,7 +216,7 @@ Move the reusable code currently under these paths into this package: - `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 from `@/webavatar-core/server`. +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 @@ -164,13 +225,15 @@ direction even though TypeScript erases them at runtime. The project should expo ### Public entry points -Create curated index files and export only supported APIs. A starting manifest shape is: +Create curated index files and export only supported APIs. The manifest: ```json { "name": "@/webavatar-core", "version": "0.1.0", + "license": "UNLICENSED", "type": "module", + "engines": { "node": ">=22.12" }, "files": ["dist"], "exports": { ".": { @@ -183,19 +246,45 @@ Create curated index files and export only supported APIs. A starting manifest s }, "./server": { "types": "./dist/server/index.d.ts", + "browser": "./dist/server/browser-stub.js", "import": "./dist/server/index.js" }, "./vite": { "types": "./dist/vite/index.d.ts", + "browser": "./dist/server/browser-stub.js", "import": "./dist/vite/index.js" } }, + "dependencies": { + "better-sqlite3": "^13.0.3", + "ws": "^8.21.3", + "@types/better-sqlite3": "^9.6.0", + "@types/ws": "^8.18.1" + }, + "peerDependencies": { + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { "optional": true } + }, "publishConfig": { "registry": "https:///api/packages//npm/" } } ``` +Notes on that manifest: + +- **The `@types/*` packages are regular dependencies, not devDependencies.** The published + declarations reference third-party types transitively: `session.ts` types the browser socket with + `ws`'s `WebSocket`, and `analytics.ts`'s `rawDb` exposes better-sqlite3's `Database`. A consumer + without those type packages sees `any` there under `skipLibCheck`, silently. Shipping the types as + dependencies is the non-invasive fix; hiding the leaks from the public surface is a later API + change. `@types/node` stays a devDependency — every consumer is a Node app and already has it. +- **Vite is an optional peer.** Only the `./vite` entry needs it, and consumers that never import + that entry must not get a peer warning. +- **Condition order matters.** `types` first, then `browser`, then `import`. + The root entry point is portable and contains contracts such as: - Protocol events and session types @@ -213,25 +302,132 @@ that future semantic versions will protect. ### Server-only safety -Moving server code into `node_modules` means its path no longer automatically lives under -SvelteKit's `$lib/server` directory. In the template app, add a local wrapper: +Today SvelteKit refuses, at build time, to let browser code import anything under `$lib/server`. +That guard is path-based and does not reach `node_modules`, so once the server code is a package the +protection has to be rebuilt in layers. All four are required: + +1. **A `browser` export condition in the package (protects every consumer).** Vite's client + environment resolves the `browser` condition; its SSR environment and plain Node do not. The + `./server` and `./vite` entries therefore map, for browser bundles only, to a stub that throws + when evaluated: + + ```ts + // packages/core/src/server/browser-stub.ts + throw new Error( + '@/webavatar-core/server is server-only and was imported into browser code.' + ); + ``` + + The point is not the error message — it is that a client bundle can never contain the server + code, in any consumer, without that consumer configuring anything. + +2. **A server-only wrapper in the app**, so SvelteKit's own guard covers all app code that goes + through it: + + ```ts + // apps/template/src/lib/server/webavatar-core.ts + export * from '@/webavatar-core/server'; + ``` + + SvelteKit routes and server modules import through this wrapper. Root-only entrypoints such as + `server.js` and `vite.config.ts` may import the package server and Vite entries directly. + +3. **A lint rule** forbidding `@/webavatar-core/server` and `@/webavatar-core/vite` + everywhere in the app except `src/lib/server/**`, `server.js`, and `vite.config.ts`. + +4. **A build-time guard in the template's Vite config**, so a direct import fails the build rather + than only failing in the browser at runtime: + + ```ts + import type { Plugin } from 'vite'; + + const SERVER_ONLY = /^@\/webavatar-core\/(server|vite)(\/|$)/; + + export function serverOnlyGuard(): Plugin { + return { + name: 'webavatar-server-only-guard', + enforce: 'pre', + resolveId(id, importer, options) { + const isClient = this.environment ? this.environment.name === 'client' : !options?.ssr; + if (isClient && SERVER_ONLY.test(id)) { + throw new Error( + `${id} is server-only; import it through $lib/server instead (importer: ${importer}).` + ); + } + return null; + } + }; + } + ``` + + Client projects receive this by copying the template. Verify it as described under "Verification + requirements". + +### Composition-root factory + +`server.js` and the `./vite` plugin currently duplicate the same wiring: load config, select the +provider, choose the analytics recorder, resolve session defaults from the persona and tool +registry, then hand everything to `handleRealtimeUpgrade`. After the migration `server.js` is a file +every client project copies and never merges again, so a core change that needed new wiring would +have to be applied by hand in every client. Collapse the duplication into one factory exported from +the `./server` entry: ```ts -// apps/template/src/lib/server/webavatar-core.ts -export * from '@/webavatar-core/server'; +export interface RealtimeRuntimeOptions { + persona: SessionPersona; + toolRegistry: ToolRegistry; + /** Literal per entrypoint: dev auto-forwards debug logs, prod requires DEBUG_ACCESS_KEY. */ + isDev: boolean; +} + +export interface RealtimeRuntime { + config: AppConfig; + provider: RealtimeProvider; + recorder: AnalyticsRecorder; + defaults: SessionDefaults; + /** Attach to an http.Server 'upgrade' event. A closure, so it can be passed unbound. */ + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void; +} + +export function createRealtimeRuntime(options: RealtimeRuntimeOptions): RealtimeRuntime; +``` + +The body is the existing composition code moved verbatim, not new behavior: `loadConfig()` (still +memoized, still fail-fast), `selectProvider`, the `Sqlite`/`Noop` recorder choice, +`resolveSessionDefaults` merged with the tool registry, and a `handleUpgrade` closure that supplies +`clientToolTimeoutMs` and `logMessages` from config. It is not a service locator: it returns an +instance, keeps no module state of its own, and is called once per process by whichever composition +root is running. `server.js` shrinks to: + +```js +import { createServer } from 'node:http'; +import { createRealtimeRuntime } from '@/webavatar-core/server'; +import { handler } from './build/handler.js'; +import { SESSION_PERSONA } from './build-server/project/persona.js'; +import { toolRegistry } from './build-server/server/project/tools/index.js'; + +const runtime = createRealtimeRuntime({ persona: SESSION_PERSONA, toolRegistry, isDev: false }); +const server = createServer(handler); +server.on('upgrade', runtime.handleUpgrade); +server.listen(runtime.config.port, () => console.log(`listening on port ${runtime.config.port}`)); ``` -SvelteKit routes and server modules should normally import through this wrapper. Add lint rules that -forbid `@/webavatar-core/server` from client and universal app files. Root-only entrypoints -such as `server.js` may import the package server entry point directly. +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. Do not make the foundational core build responsible for packaging Svelte components during -the same migration. If multiple client projects later need the panel, extract it in a separate -`@/webavatar-devtools-svelte` package as a follow-up. +logger. That test is the repository's only browser-mode Vitest test, so the browser-mode Vitest +project (`client` in the current `vite.config.ts`) moves to the app with it; the packages' own +Vitest configs are node-only. Do not make the foundational core build responsible for packaging +Svelte components during the same migration. If multiple client projects later need the panel, +extract it in a separate `@/webavatar-devtools-svelte` package as a follow-up. ## Package 2: `@/webavatar-humanoid` @@ -239,7 +435,7 @@ the same migration. If multiple client projects later need the panel, extract it Move these paths into the package: -- `src/lib/humanoid/**` +- `src/lib/humanoid/**` (including `eye/DESIGN_NOTES.md`) - `src/lib/client/humanoid/**` Keep these in the template app: @@ -254,14 +450,24 @@ stores may continue to satisfy the Svelte store contract structurally. ### Core compatibility -Humanoid currently needs core's normalized `ServerEvent` contract. Declare core as a peer dependency -so the app owns the single compatible core installation: +Humanoid needs core's normalized `ServerEvent` contract — as a type. Today that is humanoid's only +import from core (`avatar-state.ts` imports `type ServerEvent`), and the migration keeps it that +way: **humanoid's dependency on core is type-only.** The built humanoid JavaScript contains no +import of the core package; TypeScript erases `import type` statements, so at runtime humanoid is +coupled to no particular installed copy of core. + +Core is still declared as a peer dependency, because humanoid's published declarations reference +core's types and the consumer must have a compatible core installed for them to resolve. That is +also what produces the explicit compatibility error the objective asks for: npm reports a peer-range +conflict at install time. ```json { "name": "@/webavatar-humanoid", "version": "0.1.0", + "license": "UNLICENSED", "type": "module", + "engines": { "node": ">=22.12" }, "files": ["dist"], "peerDependencies": { "@/webavatar-core": "^0.1.0" @@ -293,22 +499,39 @@ so the app owns the single compatible core installation: } ``` -Use the broadest core peer range actually tested. A core release does not require a humanoid release -when the existing peer range and APIs remain compatible. +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, not ambient runtime implementations. For logging: +Humanoid may depend on core contracts, never on ambient runtime implementations, and never on core +values. **The migration adds no logging to humanoid** — nothing in the package logs today, and a +logger parameter nobody calls is speculative API surface. When the first log call site is added +later, follow this shape: the `Logger` type comes from core as a type-only import, and the default +no-op lives inside humanoid rather than being imported from core. ```ts -import { NOOP_LOGGER, type Logger } from '@/webavatar-core'; +import type { Logger } from '@/webavatar-core'; + +/** Local by design: importing core's NOOP_LOGGER would make this package's runtime depend on core for a four-line object. */ +const NOOP_LOGGER: Logger = { debug() {}, info() {}, warn() {}, error() {} }; export interface EyeGazeControllerOptions { - config?: Partial; + rng?: () => number; + scheduleFrame?: (callback: (nowMs: number) => void) => () => void; logger?: Logger; } -export function createEyeGazeController(options: EyeGazeControllerOptions = {}) { +export function createEyeGazeController( + overrides: DeepPartial = {}, + options: EyeGazeControllerOptions = {} +) { const logger = options.logger ?? NOOP_LOGGER; // Stateful/effectful wrapper may log under `humanoid.eye`. } @@ -320,10 +543,7 @@ The app supplies the implementation: import { clientLogger } from '@/webavatar-core/client'; import { createEyeGazeController } from '@/webavatar-humanoid/eye/client'; -const gaze = createEyeGazeController({ - config: projectEyeConfig, - logger: clientLogger -}); +const gaze = createEyeGazeController(projectEyeConfig, { logger: clientLogger }); ``` Apply the same dependency-injection pattern to future clocks, randomness sources, metrics, or other @@ -361,9 +581,112 @@ Prefer importing from curated entry points instead of recreating one export per two old modules expose names that collide, use a deliberate subpath or rename the public API rather than leaking the old filesystem layout wholesale. -The custom production server should import built package output through package names. Once that -works, remove the app's special `tsconfig.server.json`/`build-server` compilation path if it is no -longer needed. Confirm that adapter-node deployment still includes the runtime package dependencies. +### 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