# Wiring Sonik into a host app for local development

This is the host-app side of the [LOCAL_DEVELOPMENT](./LOCAL_DEVELOPMENT.md) flow: getting [yalc](https://github.com/wclr/yalc) + Vite (+ Docker, when applicable) set up so changes to Sonik on your machine show up in the host app's browser without going through npm.

Two flavors covered:

1. [Single-app Vite project](#1-single-app-vite-project) (e.g. `mx-frontend`) — direct setup at the project root.
2. [Dockerized monorepo](#2-dockerized-monorepo-eg-loop-returns-app--frontend-v2) (e.g. `loop-returns-app/frontend-v2`) — setup scoped to a sub-workspace with extra notes for the container.

Both flavors use the same `yalcWatch.ts` helper, inlined [at the bottom](#yalcwatchts) of this doc so you don't need access to any other repo.

---

## 1. Single-app Vite project

### `config/yalcWatch.ts`

Drop in [the file below](#yalcwatchts) at `config/yalcWatch.ts`.

### `vite.config.ts`

Use `defineConfig`'s function form so `mode` is available, then spread `getYalcWatchAliases(...)`'s output into `resolve.alias`:

```ts
// vite.config.ts
import { defineConfig } from 'vite'
import { getYalcWatchAliases } from './config/yalcWatch'

const YALC_LIBS = ['@loophq/sonik']

export default defineConfig(({ mode }) => ({
  // ...
  resolve: {
    alias: {
      ...getYalcWatchAliases({ yalcLibs: YALC_LIBS, mode }),
      // ...your existing aliases
    },
  },
}))
```

The helper is a no-op for any `mode !== 'development'`, so production builds and Vitest (`mode === 'test'`) are unaffected.

### `.gitignore`

```
.yalc
yalc.lock
```

### Pre-commit hook: `npx yalc check`

`yalc add` rewrites your `package.json` dependency from `"@loophq/sonik": "^X.Y.Z"` to `"@loophq/sonik": "file:.yalc/@loophq/sonik"`. Committing that breaks CI for everyone, so guard against it with a pre-commit hook.

If the host app uses [lefthook](https://lefthook.dev/) (preferred — what `mx-frontend` uses):

```yaml
# lefthook.yml
pre-commit:
  parallel: false
  jobs:
    - name: check-yalc
      run: npx yalc check
      glob: "{package.json,package-lock.json}"
```

If the host app uses [husky](https://typicode.github.io/husky/) instead, add `npx yalc check` to `.husky/pre-commit`. For background, see [mx-frontend MR !33](https://gitlab.com/loopreturns/applications/mx-frontend/-/merge_requests/33).

If the host app has no commit-time hooks, fall back to a CI job that fails when `frontend/package.json` contains a `file:.yalc/...` reference (this is what `loop-returns-app/frontend-v2` does — see the section below).

---

## 2. Dockerized monorepo (e.g. `loop-returns-app` → `frontend-v2`)

This is the verified setup from a 2026-05-12 run against `loop-returns-app/frontend-v2/`. Vite logs `Yalc watching @loophq/sonik...` on container start and HMR fires on subsequent `yalc publish --push` without any container restart.

### Where things live

All paths are relative to the monorepo root unless noted. Sub-workspace = `frontend-v2/`:

| File | Location |
| --- | --- |
| Helper | `frontend-v2/config/yalcWatch.ts` |
| Vite config | `frontend-v2/vite.config.ts` |
| Sub-workspace gitignore | `frontend-v2/.gitignore` |
| Lefthook config (if applicable) | `lefthook.yml` (monorepo root) |
| Compose service | `core-frontend-v2` in `compose.yml` |

### `frontend-v2/config/yalcWatch.ts`

Use the same file as the single-app case — see [yalcWatch.ts](#yalcwatchts) below.

### `frontend-v2/vite.config.ts`

```ts
import { defineConfig } from 'vite'
import { getYalcWatchAliases } from './config/yalcWatch'

const YALC_LIBS = ['@loophq/sonik']

export default defineConfig(({ mode }) => ({
  // ...plugins, base, etc.
  resolve: {
    alias: {
      ...getYalcWatchAliases({ yalcLibs: YALC_LIBS, mode }),
      '@': fileURLToPath(new URL('./src', import.meta.url)),
      // ...
    },
  },
}))
```

### `frontend-v2/.gitignore`

```
# Yalc (local development of Loop libs, e.g. @loophq/sonik)
.yalc
yalc.lock
```

### Lefthook (monorepo root)

Lefthook lives at the monorepo root, so scope the job to the sub-workspace with `root:` + a sub-workspace `glob:`:

```yaml
# lefthook.yml (monorepo root)
pre-commit:
  parallel: false
  jobs:
    - name: check-yalc-frontend-v2
      root: "frontend-v2/"
      run: npx yalc check
      glob: "frontend-v2/{package.json,package-lock.json}"
```

### CI backstop (if you don't have a commit hook)

`.yalc/` is gitignored, so if a stray `"@loophq/sonik": "file:.yalc/..."` lands on `package.json`, the next CI `npm ci` fails with `ENOENT` on `.yalc/@loophq/sonik`. That's a useful safety net but produces a confusing failure — prefer the lefthook job above when feasible.

### Docker behavior

The `core-frontend-v2` service in `compose.yml` mounts the sub-workspace as a bind mount with an anonymous `node_modules` volume:

```yaml
core-frontend-v2:
  # ...
  volumes:
    - "./frontend-v2:/app/v2"      # bind mount: host file changes visible in container
    - "/app/v2/node_modules"       # anonymous volume: container owns its own install
```

Two consequences:

- **`.yalc/` is visible inside the container** the moment you `yalc add @loophq/sonik` on the host — the bind mount carries it across. No image rebuild needed.
- **The container does its own `npm install` on start**, so the `file:.yalc/...` reference that `yalc add` writes into `package.json` only takes effect once the container reinstalls.

### Workflow (verified)

1. **In the sonik repo** (on your host):

   ```sh
   npm run publish:local
   # which is: yalc publish --push
   ```

   `yalc` only publishes what `npm publish` would publish (per `.npmignore`). If you've added a new export to `lib/`, make sure it's actually included in the published copy — `npm pack --dry-run` from sonik is the quickest check.

2. **In the sub-workspace** (`frontend-v2/`, on your host):

   ```sh
   yalc add @loophq/sonik
   ```

   This creates `.yalc/@loophq/sonik/`, flips `package.json` to `"@loophq/sonik": "file:.yalc/@loophq/sonik"`, and creates `yalc.lock`. All three are gitignored.

3. **Restart the container once** so its startup `npm install` picks up the `file:` reference:

   ```sh
   docker compose restart core-frontend-v2
   ```

   Container logs on the next Vite boot should include `Yalc watching @loophq/sonik...` — that's the helper confirming the alias wired up.

4. **Iterate.** For every subsequent sonik edit, run `yalc publish --push` from sonik. The bind mount carries the updated files into the container, Vite's watcher fires, and the browser HMRs. **No `docker compose restart` needed** — the alias points directly into `.yalc/`, which the bind mount keeps fresh.

5. **When you're done** (in the sub-workspace):

   ```sh
   yalc remove @loophq/sonik
   docker compose restart core-frontend-v2
   ```

   `yalc remove` restores `package.json` to the registry version; the restart reinstalls it inside the container. After the restart, `npx yalc check` exits `0` and `git status` shows no `.yalc/`, no `yalc.lock`, and no `file:` reference in `package.json`.

### Troubleshooting (Docker)

| Symptom | Likely cause |
| --- | --- |
| No `Yalc watching @loophq/sonik...` after restart | `.yalc/@loophq/sonik/` doesn't exist inside the container — confirm with `docker compose exec core-frontend-v2 ls /app/v2/.yalc/@loophq/sonik`. |
| `Failed to resolve import "@loophq/sonik/..."` after `yalc add` | Sonik was published without the file you're importing — re-run sonik's build, then `yalc publish --push`. |
| Browser shows old code after `yalc publish --push` | Check container logs for `[vite] hmr update`. If absent, hard-reload the browser. If still stale, `docker compose exec core-frontend-v2 ls /app/v2/.yalc/@loophq/sonik/` to confirm the bind mount actually updated. |
| CI fails with `ENOENT` on `.yalc/@loophq/sonik` | A `file:.yalc/...` reference made it into a committed `package.json` — run `yalc remove @loophq/sonik`, restart the container, then commit. |
| Container still uses the registry version after `yalc add` | The container hasn't re-run `npm install` since the `package.json` flip — `docker compose restart core-frontend-v2`. |

---

## `yalcWatch.ts`

Canonical helper. The original lives behind GitLab SSO at [`mx-frontend/config/yalcWatch.ts`](https://gitlab.com/loopreturns/applications/mx-frontend/-/blob/main/config/yalcWatch.ts) — inlined here so this doc is self-contained. Either drop it at `config/yalcWatch.ts` (single-app) or `<sub-workspace>/config/yalcWatch.ts` (monorepo).

```ts
// config/yalcWatch.ts
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath, URL } from 'node:url'

/**
 * Generate Vite aliases for local Yalc packages.
 * Enables HMR and source linking for libraries in `.yalc/`.
 */

const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const YALC_DIR = path.join(ROOT_DIR, '.yalc')

type YalcExportTarget = string | { import?: string; [key: string]: unknown } | null
type YalcPackageJson = {
  name: string
  exports?: Record<string, YalcExportTarget>
}

interface YalcWatchOptions {
  yalcLibs?: string[]
  mode?: string
}

/** Resolve export path relative to a given package directory. */
function resolveExportPath(pkgDir: string, exportPath: string): string {
  return exportPath.startsWith('./') ? path.resolve(pkgDir, exportPath) : exportPath
}

export function getYalcWatchAliases({
  yalcLibs = [],
  mode = '',
}: YalcWatchOptions = {}): Record<string, string> {
  const aliases: Record<string, string> = {}

  if (mode !== 'development') {
    return aliases
  }

  yalcLibs.forEach((localLib) => {
    if (fs.existsSync(`${YALC_DIR}/${localLib}`)) {
      const pkgPath = fileURLToPath(new URL(`${YALC_DIR}/${localLib}`, import.meta.url))

      const pkgJsonPath = path.join(pkgPath, 'package.json')
      const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as YalcPackageJson
      const exportsField = pkgJson.exports

      console.info(`Yalc watching ${localLib}...`)

      if (!exportsField) {
        aliases[localLib] = pkgPath

        return
      }

      // Sort exports by string length so that Vite can successfully resolve all aliases.
      const sortedExports = Object.entries(exportsField).toSorted(
        (a, b) => b[0].length - a[0].length,
      )

      sortedExports.forEach(([subpath, target]) => {
        let resolvedPath: string | undefined

        if (typeof target === 'string') {
          resolvedPath = resolveExportPath(pkgPath, target)
        } else if (
          target !== null &&
          typeof target === 'object' &&
          typeof target.import === 'string'
        ) {
          resolvedPath = resolveExportPath(pkgPath, target.import)
        }

        if (!resolvedPath) {
          return
        }

        const aliasKey = subpath === '.' ? pkgJson.name : path.join(pkgJson.name, subpath)

        aliases[aliasKey] = resolvedPath
      })
    }
  })

  return aliases
}
```

If your host app's TS config doesn't have `Array.prototype.toSorted` (i.e. anything below ES2023 / Node 20+), swap `.toSorted(...)` for `.slice().sort(...)`.
