open-design/apps/daemon/src/active-context-routes.ts
Golden Obsidian LLC aa0616062d
refactor(daemon): introduce HTTP Request Adapter + typed Deps (#2636)
* refactor(daemon): introduce HTTP Request Adapter + typed Deps (proof on active-context-routes)

Adds a typed HTTP boundary Adapter under apps/daemon/src/http/ that replaces
the untyped ServerContext service-locator pattern (30+ fields, mostly any)
for route handlers. Routes become pure (input, deps) -> Result<output>
functions, unit-testable without Express or supertest.

Six new modules under apps/daemon/src/http/:
  - types.ts        Result<T,E>, ok(), err(), JsonRouteSpec, Handler,
                    RouteInputContext, HttpMethod, InputParser
  - parse.ts        rawInput(req), validationError(message, issues?)
  - response.ts     sendJson(), sendApiError(), statusForError() +
                    ERROR_STATUS_BY_CODE map
  - origin-guard.ts guardSameOrigin(req, origin) wrapping isLocalSameOrigin
                    as a Result
  - adapter.ts      defineJsonRoute(), mountJsonRoute() (only place that
                    knows about req/res)
  - index.ts        barrel

active-context-routes.ts migrated as proof of pattern. parsePostActive(),
handlePostActive(), handleGetActive() are now pure functions; postActiveRoute
and getActiveRoute are exported route specs. The wire signature
registerActiveContextRoutes(app, ctx) is preserved so server.ts is untouched.

Spec at specs/current/daemon-http-adapter.md captures the strangler migration
order for the remaining route files (mcp-routes, chat-routes, artifact
routes, etc.) and a StreamRoute follow-up where the Run Orchestrator lands.

Wire-format note: cross-origin response moves from the legacy
{ error: 'cross-origin request rejected' } shape to the structured
{ error: { code: 'FORBIDDEN', message: ... } } shape. Backwards-compatible
via the existing CompatibleErrorResponse = ApiErrorResponse | LegacyErrorResponse
union in @open-design/contracts.

Validation:
  - pnpm install (post-rebase, exit 0)
  - pnpm --filter @open-design/daemon typecheck (both tsconfig.json and
    tsconfig.tests.json silent => pass)
  - pnpm --filter @open-design/daemon test: 15 new tests pass
    (tests/http/adapter.test.ts + tests/active-context-routes.test.ts).
    84 pre-existing failures across 23 files are unchanged and unrelated
    to this PR (Windows symlink / short-name / colon-in-filename, upstream
    behavior drift, missing plugin marketplace fixtures, and a freshly-
    added tools-connectors-cli suite of 38 failures that landed during
    the rebase).

Sharpens W4/W5 of specs/current/maintainability-roadmap.md and unlocks
W6 (Run Orchestrator).

* chore: add core-js, electron-winstaller, protobufjs, sharp to pnpm.onlyBuiltDependencies
2026-05-22 15:20:15 +08:00

128 lines
3.9 KiB
TypeScript

import type { Express } from 'express';
import { createApiError } from '@open-design/contracts';
import { ACTIVE_CONTEXT_TTL_MS } from './constants.js';
import type { RouteDeps } from './server-context.js';
import { defineJsonRoute, err, mountJsonRoute, ok, type Result } from './http/index.js';
export interface RegisterActiveContextRoutesDeps extends RouteDeps<'db' | 'http' | 'projectStore'> {}
// Soft "what is the user looking at right now in Open Design?" channel. The
// web UI POSTs the current project + file on every route change; the MCP
// surface reads it so a coding agent in another repo can resolve "the design
// I have open" without the user typing the project id. In-memory only —
// daemon restart clears it.
interface ActiveContext {
projectId: string;
fileName: string | null;
ts: number;
}
interface ActiveContextStore {
current: ActiveContext | null;
}
type PostActiveInput =
| { kind: 'clear' }
| { kind: 'set'; projectId: string; fileName: string | null };
type PostActiveOutput =
| { active: false }
| { active: true; projectId: string; fileName: string | null; ts: number };
type GetActiveOutput =
| { active: false }
| {
active: true;
projectId: string;
projectName: string | null;
fileName: string | null;
ts: number;
ageMs: number;
};
interface ActiveContextDomainDeps {
store: ActiveContextStore;
db: unknown;
getProject: (db: unknown, projectId: string) => { name?: string | null } | null | undefined;
now: () => number;
}
function parsePostActive(raw: { body: unknown }): Result<PostActiveInput> {
const body = (raw.body ?? {}) as Record<string, unknown>;
if (body.active === false) {
return ok({ kind: 'clear' });
}
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
if (!projectId) {
return err(createApiError('BAD_REQUEST', 'projectId is required'));
}
const fileName =
typeof body.fileName === 'string' && body.fileName.length > 0 ? body.fileName : null;
return ok({ kind: 'set', projectId, fileName });
}
function handlePostActive(
input: PostActiveInput,
deps: ActiveContextDomainDeps,
): Result<PostActiveOutput> {
if (input.kind === 'clear') {
deps.store.current = null;
return ok({ active: false });
}
const next: ActiveContext = {
projectId: input.projectId,
fileName: input.fileName,
ts: deps.now(),
};
deps.store.current = next;
return ok({ active: true, ...next });
}
function handleGetActive(
_input: void,
deps: ActiveContextDomainDeps,
): Result<GetActiveOutput> {
const current = deps.store.current;
if (!current || deps.now() - current.ts > ACTIVE_CONTEXT_TTL_MS) {
deps.store.current = null;
return ok({ active: false });
}
const project = deps.getProject(deps.db, current.projectId);
return ok({
active: true,
projectId: current.projectId,
projectName: project?.name ?? null,
fileName: current.fileName,
ts: current.ts,
ageMs: deps.now() - current.ts,
});
}
export const postActiveRoute = defineJsonRoute<PostActiveInput, PostActiveOutput, ActiveContextDomainDeps>({
method: 'post',
path: '/api/active',
requireSameOrigin: true,
parse: parsePostActive,
handle: handlePostActive,
});
export const getActiveRoute = defineJsonRoute<void, GetActiveOutput, ActiveContextDomainDeps>({
method: 'get',
path: '/api/active',
requireSameOrigin: true,
parse: () => ok(undefined),
handle: handleGetActive,
});
export function registerActiveContextRoutes(app: Express, ctx: RegisterActiveContextRoutesDeps): void {
const store: ActiveContextStore = { current: null };
const domainDeps: ActiveContextDomainDeps = {
store,
db: ctx.db,
getProject: ctx.projectStore.getProject,
now: () => Date.now(),
};
const adapter = { resolvedPortRef: ctx.http.resolvedPortRef };
mountJsonRoute(app, postActiveRoute, domainDeps, adapter);
mountJsonRoute(app, getActiveRoute, domainDeps, adapter);
}