avatar vian's notes

Learning in public, one post at a time

  • HOME
  • CATEGORIES
  • TAGS
  • ARCHIVES
  • ABOUT
Home The Server Split That Almost Didn't Happen
Post

The Server Split That Almost Didn't Happen

Posted Aug 20, 2026 Updated Aug 6, 2026
By Alvian
4 min read
The Server Split That Almost Didn't Happen
The Server Split That Almost Didn't Happen

Three months ago I wrote about the monorepo split. API server, dashboard, CLI — three workspaces instead of one blob. That was the starting point.

The next step was obvious: run the API server without the dashboard. Lightweight mode. You’re hitting /v1/chat/completions, not /dashboard/combos. But they’re the same process. That bothered me.

I’d sketched it out. Then stalled for two months.

Why It Got Stuck

The obvious approach was copying. Take the chat handlers, paste into a new directory, wire up Express routes. I’d done that with the monorepo split.

But I’d also just spent a week merging upstream changes, discovering that every copied file becomes a future conflict. apps/api/ and apps/dashboard/ — both inside the repo, both drifting from upstream with every merge I pulled.

Copying the handlers would mean the same thing, again: a parallel codebase I’d have to babysit through every upstream update. One bad merge where I fixed something in the fork but not upstream, and now the two handlers do the same thing slightly differently. That’s the split that would have haunted me.

I wanted a cleaner boundary. I just didn’t know what “clean” looked like yet.

The Answer That Was Already There

The answer was sitting in the monorepo structure itself.

What I needed was the inverse: a repo that imports from 9router, rather than a fork that contains it.

The architecture that finally worked:

1
2
3
4
5
6
7
8
9
10
9router-api/               ← my new repo
├── server.ts              # Express, ~120 lines
├── tsconfig.json          # @/ → 9router/src, open-sse/*
└── src/
    └── exports.js         # Re-exports from 9router

9router/                  ← upstream (decolua/9router)
├── src/
├── open-sse/
└── ...                    # Source of truth

The dependency works via tsconfig.json path aliases. Inside exports.js, @/ points at 9router/src/, not the API repo’s own src/. Both repos live as siblings:

1
2
3
~/Documents/alvian/
├── 9router/              # 9router source
└── 9router-api/          # new API repo

No npm link, no git submodule. Just two directories and a path mapping.

The Technical Detail That Almost Blocked Everything

Path aliases.

9router uses @/ to mean src/, everywhere. Next.js resolves this automatically. Express doesn’t know what @/ means.

I initially tried copying the handlers. Then I hit every import statement that said from '@/lib/localDb' and realized I’d be doing search-and-replace across a dozen files just to make it run.

The solution was tsx. It handles ESM natively and reads tsconfig.json path mappings:

1
2
3
4
5
6
7
8
{
  "compilerOptions": {
    "paths": {
      "@/*": ["../9router/src/*"],
      "open-sse/*": ["../9router/open-sse/*"]
    }
  }
}

exports.js becomes the contract between the two:

1
2
3
export { handleChat } from '@/sse/handlers/chat.js';
export { getSettings, validateApiKey } from '@/lib/localDb.js';
export { initConsoleLogCapture, getConsoleLogs, getConsoleEmitter } from '@/lib/consoleLogBuffer.js';

When upstream adds a new export, I add it to exports.js. When upstream updates a handler, the API server gets it on restart. No copy-paste drift.

The server.ts mounts the handlers:

1
2
3
4
5
6
7
8
9
10
11
12
import { handleChat, initConsoleLogCapture, getConsoleEmitter, getConsoleLogs } from './src/exports.js';

app.post('/v1/chat/completions', handleChat);

app.get('/api/translator/console-logs/stream', (req, res) => {
  const emitter = getConsoleEmitter();
  res.setHeader('Content-Type', 'text/event-stream');
  emitter.on('line', (line) => {
    res.write(`data: ${JSON.stringify({ type: 'line', line })}\n\n`);
  });
  req.on('close', () => emitter.off('line', onLine));
});

The Console Log Problem

One feature almost stopped the whole thing.

The dashboard’s live console log page streams via SSE — /api/translator/console-logs/stream. When I run the API server without the dashboard, that stream disappears. The console log page would point at nothing.

The original implementation kept console logs in-process via consoleLogBuffer.js — an EventEmitter that patches console.log and fans out to SSE subscribers. It was tightly coupled to the Next.js process.

The fix: export the same EventEmitter from exports.js. The API server exposes the same SSE endpoint. The dashboard’s console log page works the same way, whether the API runs standalone or inside Next.js. No redesign — just an export.

Two Modes

ModeCommandMemory
Full9r-up~400MB
API only9r-api-only~120MB

Same SQLite. Same combo routing. Same provider fallback chains. The difference is what doesn’t load: React, Monaco editor, Recharts, all the UI scaffolding. Memory measured via PM2 RSS.

What This Actually Changed

The API server isn’t a fork. It’s a separate repo that imports from upstream.

  • Upstream adds vision combos? Restart, get them.
  • Upstream fixes a token refresh bug? Restart, fixed.
  • I add a custom combo in my fork? Same database, same combos file. It gets the combo automatically.

The boundary is cleaner than a fork-within-a-fork. The 9router-api repo has no history of its own — it exists to consume 9router’s history.

What Didn’t Change

The dashboard still runs full Next.js. When I’m at my desk, I want the UI — console log page, combo editor, provider management. All of it works in full mode.

The split is optional. That’s the point.


Sources

  • 9router-api — standalone API server
  • 9router upstream
  • My 9router fork
  • tsx — TypeScript execute engine
  • The monorepo split
  • Checking upstream v0.5.50
9router technical architecture
This post is licensed under CC BY-NC 4.0 by the author.
Share

Recently Updated

  • The 403 That Wasn't: How Warp, 9router, and Cloudflare Masked Two Separate Failures
  • Building Memory Into 9router: A Proxy-Layer Experiment
  • The Tunnel
  • This Is My ADE
  • Why I Left GitHub Copilot

Trending Tags

9router technical tooling personal ai postmortem warp ade analytics agents

Contents

Further Reading

Jul 31, 2026

The 403 That Wasn't: How Warp, 9router, and Cloudflare Masked Two Separate Failures

My Warp agent stopped talking to 9router. Quick background: 9router is my AI routing layer — a Hono server that proxies LLM requests through fallback providers. It runs locally via a Cloudflare tu...

Jul 24, 2026

Building Memory Into 9router: A Proxy-Layer Experiment

Every coding AI I tried was great at problems, terrible at context. I’d start a new session and describe my stack, my preferences, my ongoing projects — everything the AI needed to be useful. Next...

Jul 8, 2026

This Is My ADE

I hit send on a prompt. Watched the spinner. Started calculating — was I past the cap yet? Three weeks into the billing cycle, OpenCode Go had hit its limit. I’d been conditioned to expect the erro...

© 2026 Alvian. Some rights reserved.

This blog is open source — view on GitHub

Using the Chirpy theme for Jekyll.

Trending Tags

9router technical tooling personal ai postmortem warp ade analytics agents