mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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
128 lines
3.9 KiB
TypeScript
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);
|
|
}
|