feat(FN-2166): persist dev server script configuration across sessions
- Extend dev server store with config defaults, normalization, and JSON persistence alongside runtime state. - Add GET/PUT /api/dev-server/config endpoints with strict request validation for nullable fields and preview URLs. - Add dashboard API helpers plus a useDevServerConfig hook to load and update selected script, source, command, and preview override. - Update DevServerView and styles to support saved script selection, change/clear actions, and synchronized command/preview inputs. - Expand dev server store/routes/component tests and document the config endpoint in architecture docs.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { request } from "../test-request.js";
|
||||
import { createDevServerRouter, destroyAllDevServerManagers } from "../dev-server-routes.js";
|
||||
|
||||
function createProjectRoot(): string {
|
||||
return mkdtempSync(join(os.tmpdir(), "fn-dev-server-config-routes-"));
|
||||
}
|
||||
|
||||
function buildApp(projectRoot: string): express.Express {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/dev-server", createDevServerRouter({ projectRoot }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("dev-server config routes", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await destroyAllDevServerManagers();
|
||||
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /api/dev-server/config returns defaults when config file is missing", async () => {
|
||||
const root = createProjectRoot();
|
||||
tempDirs.push(root);
|
||||
|
||||
const app = buildApp(root);
|
||||
const res = await request(app, "GET", "/api/dev-server/config");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
selectedScript: null,
|
||||
selectedSource: null,
|
||||
selectedCommand: null,
|
||||
previewUrlOverride: null,
|
||||
detectedPreviewUrl: null,
|
||||
selectedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("PUT /api/dev-server/config saves a valid partial update", async () => {
|
||||
const root = createProjectRoot();
|
||||
tempDirs.push(root);
|
||||
|
||||
const app = buildApp(root);
|
||||
const res = await request(
|
||||
app,
|
||||
"PUT",
|
||||
"/api/dev-server/config",
|
||||
JSON.stringify({
|
||||
selectedScript: "dev",
|
||||
selectedSource: "root",
|
||||
selectedCommand: "vite",
|
||||
selectedAt: "2026-04-19T15:00:00.000Z",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
selectedScript: "dev",
|
||||
selectedSource: "root",
|
||||
selectedCommand: "vite",
|
||||
selectedAt: "2026-04-19T15:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("PUT /api/dev-server/config returns 400 for empty body", async () => {
|
||||
const root = createProjectRoot();
|
||||
tempDirs.push(root);
|
||||
|
||||
const app = buildApp(root);
|
||||
const res = await request(
|
||||
app,
|
||||
"PUT",
|
||||
"/api/dev-server/config",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PUT /api/dev-server/config returns 400 for invalid previewUrlOverride", async () => {
|
||||
const root = createProjectRoot();
|
||||
tempDirs.push(root);
|
||||
|
||||
const app = buildApp(root);
|
||||
const res = await request(
|
||||
app,
|
||||
"PUT",
|
||||
"/api/dev-server/config",
|
||||
JSON.stringify({ previewUrlOverride: "localhost:3000" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEV_SERVER_CONFIG_DEFAULTS,
|
||||
DEV_SERVER_DEFAULT_STATE,
|
||||
DEV_SERVER_LOG_MAX_LINES,
|
||||
DevServerStore,
|
||||
@@ -16,7 +17,7 @@ function createTempProject(): string {
|
||||
return mkdtempSync(join(os.tmpdir(), "fn-dev-server-store-"));
|
||||
}
|
||||
|
||||
function readPersistedState(projectDir: string): Record<string, unknown> {
|
||||
function readPersistedStoreFile(projectDir: string): Record<string, unknown> {
|
||||
const filePath = join(projectDir, ".fusion", "dev-server.json");
|
||||
return JSON.parse(readFileSync(filePath, "utf-8")) as Record<string, unknown>;
|
||||
}
|
||||
@@ -92,6 +93,132 @@ describe("DevServerStore", () => {
|
||||
await store.load();
|
||||
|
||||
expect(store.getState()).toEqual(DEV_SERVER_DEFAULT_STATE());
|
||||
expect(store.getConfig()).toEqual(DEV_SERVER_CONFIG_DEFAULTS);
|
||||
});
|
||||
|
||||
it("loading from missing file initializes with default config", async () => {
|
||||
const projectDir = createTempProject();
|
||||
tempDirs.push(projectDir);
|
||||
|
||||
const store = new DevServerStore(projectDir);
|
||||
await store.load();
|
||||
|
||||
expect(store.getConfig()).toEqual(DEV_SERVER_CONFIG_DEFAULTS);
|
||||
});
|
||||
|
||||
it("loading from valid JSON populates config", async () => {
|
||||
const projectDir = createTempProject();
|
||||
tempDirs.push(projectDir);
|
||||
|
||||
mkdirSync(join(projectDir, ".fusion"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectDir, ".fusion", "dev-server.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
config: {
|
||||
selectedScript: "dev",
|
||||
selectedSource: "apps/web",
|
||||
selectedCommand: "pnpm dev",
|
||||
previewUrlOverride: "http://localhost:4173",
|
||||
detectedPreviewUrl: "http://localhost:3000",
|
||||
selectedAt: "2026-04-19T12:00:00.000Z",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const store = new DevServerStore(projectDir);
|
||||
await store.load();
|
||||
|
||||
expect(store.getConfig()).toEqual({
|
||||
selectedScript: "dev",
|
||||
selectedSource: "apps/web",
|
||||
selectedCommand: "pnpm dev",
|
||||
previewUrlOverride: "http://localhost:4173",
|
||||
detectedPreviewUrl: "http://localhost:3000",
|
||||
selectedAt: "2026-04-19T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("updateConfig merges partial updates and persists to disk", async () => {
|
||||
const projectDir = createTempProject();
|
||||
tempDirs.push(projectDir);
|
||||
|
||||
const store = new DevServerStore(projectDir);
|
||||
await store.load();
|
||||
|
||||
const updated = await store.updateConfig({
|
||||
selectedScript: "start",
|
||||
selectedSource: "root",
|
||||
selectedCommand: "next dev",
|
||||
selectedAt: "2026-04-19T13:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(updated).toEqual({
|
||||
...DEV_SERVER_CONFIG_DEFAULTS,
|
||||
selectedScript: "start",
|
||||
selectedSource: "root",
|
||||
selectedCommand: "next dev",
|
||||
selectedAt: "2026-04-19T13:00:00.000Z",
|
||||
});
|
||||
|
||||
const persisted = readPersistedStoreFile(projectDir) as {
|
||||
config: Record<string, string | null>;
|
||||
};
|
||||
|
||||
expect(persisted.config).toMatchObject({
|
||||
selectedScript: "start",
|
||||
selectedSource: "root",
|
||||
selectedCommand: "next dev",
|
||||
selectedAt: "2026-04-19T13:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("updateConfig overwrites previous values", async () => {
|
||||
const projectDir = createTempProject();
|
||||
tempDirs.push(projectDir);
|
||||
|
||||
const store = new DevServerStore(projectDir);
|
||||
await store.load();
|
||||
|
||||
await store.updateConfig({ selectedScript: "dev", previewUrlOverride: "http://localhost:3000" });
|
||||
const updated = await store.updateConfig({ selectedScript: "serve", previewUrlOverride: null });
|
||||
|
||||
expect(updated.selectedScript).toBe("serve");
|
||||
expect(updated.previewUrlOverride).toBeNull();
|
||||
});
|
||||
|
||||
it("saveConfig persists full config payload", async () => {
|
||||
const projectDir = createTempProject();
|
||||
tempDirs.push(projectDir);
|
||||
|
||||
const store = new DevServerStore(projectDir);
|
||||
await store.load();
|
||||
|
||||
await store.saveConfig({
|
||||
selectedScript: "storybook",
|
||||
selectedSource: "apps/docs",
|
||||
selectedCommand: "storybook dev -p 6006",
|
||||
previewUrlOverride: "http://localhost:6006",
|
||||
detectedPreviewUrl: "http://localhost:6006",
|
||||
selectedAt: "2026-04-19T14:00:00.000Z",
|
||||
});
|
||||
|
||||
const persisted = readPersistedStoreFile(projectDir) as {
|
||||
config: Record<string, string | null>;
|
||||
};
|
||||
|
||||
expect(persisted.config).toEqual({
|
||||
selectedScript: "storybook",
|
||||
selectedSource: "apps/docs",
|
||||
selectedCommand: "storybook dev -p 6006",
|
||||
previewUrlOverride: "http://localhost:6006",
|
||||
detectedPreviewUrl: "http://localhost:6006",
|
||||
selectedAt: "2026-04-19T14:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("updateState merges partial updates and persists to disk", async () => {
|
||||
@@ -116,7 +243,7 @@ describe("DevServerStore", () => {
|
||||
name: "default",
|
||||
});
|
||||
|
||||
const persisted = readPersistedState(projectDir) as { state: Record<string, unknown> };
|
||||
const persisted = readPersistedStoreFile(projectDir) as { state: Record<string, unknown> };
|
||||
expect(persisted.state).toMatchObject({
|
||||
id: "abc",
|
||||
command: "pnpm dev",
|
||||
@@ -151,7 +278,7 @@ describe("DevServerStore", () => {
|
||||
|
||||
expect(store.getState().logHistory).toEqual(["line one", "line two"]);
|
||||
|
||||
const persisted = readPersistedState(projectDir) as { state: { logHistory: string[] } };
|
||||
const persisted = readPersistedStoreFile(projectDir) as { state: { logHistory: string[] } };
|
||||
expect(persisted.state.logHistory).toEqual(["line one", "line two"]);
|
||||
});
|
||||
|
||||
@@ -184,7 +311,7 @@ describe("DevServerStore", () => {
|
||||
|
||||
expect(store.getState().logHistory).toEqual([]);
|
||||
|
||||
const persisted = readPersistedState(projectDir) as { state: { logHistory: string[] } };
|
||||
const persisted = readPersistedStoreFile(projectDir) as { state: { logHistory: string[] } };
|
||||
expect(persisted.state.logHistory).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import { glob, readFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
|
||||
/** Script names in priority order (most likely first) */
|
||||
export const DEV_SCRIPT_NAMES = ["dev", "start", "web", "frontend", "serve", "storybook", "preview"] as const;
|
||||
export const DEV_SERVER_SCRIPT_NAMES = ["dev", "start", "serve", "web", "frontend", "preview", "storybook"] as const;
|
||||
/** @deprecated use DEV_SERVER_SCRIPT_NAMES */
|
||||
export const DEV_SCRIPT_NAMES = DEV_SERVER_SCRIPT_NAMES;
|
||||
|
||||
/** Framework indicators in devDependencies/dependencies */
|
||||
export const FRAMEWORK_INDICATORS = [
|
||||
@@ -47,20 +49,24 @@ async function readPackageJson(filePath: string): Promise<PackageJsonShape | nul
|
||||
}
|
||||
}
|
||||
|
||||
export function isCandidateScript(name: string): boolean {
|
||||
return DEV_SERVER_SCRIPT_NAMES.includes(name as (typeof DEV_SERVER_SCRIPT_NAMES)[number]);
|
||||
}
|
||||
|
||||
function getScriptPriorityScore(scriptName: string): number {
|
||||
const index = DEV_SCRIPT_NAMES.indexOf(scriptName as (typeof DEV_SCRIPT_NAMES)[number]);
|
||||
const index = DEV_SERVER_SCRIPT_NAMES.indexOf(scriptName as (typeof DEV_SERVER_SCRIPT_NAMES)[number]);
|
||||
if (index === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (DEV_SCRIPT_NAMES.length <= 1) {
|
||||
if (DEV_SERVER_SCRIPT_NAMES.length <= 1) {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
const maxBoost = 0.5;
|
||||
const minBoost = 0.2;
|
||||
const delta = maxBoost - minBoost;
|
||||
const ratio = index / (DEV_SCRIPT_NAMES.length - 1);
|
||||
const ratio = index / (DEV_SERVER_SCRIPT_NAMES.length - 1);
|
||||
return maxBoost - (ratio * delta);
|
||||
}
|
||||
|
||||
@@ -115,7 +121,7 @@ function extractScripts(pkg: PackageJsonShape): Array<{ name: string; command: s
|
||||
const scripts = pkg.scripts ?? {};
|
||||
const output: Array<{ name: string; command: string }> = [];
|
||||
|
||||
for (const scriptName of DEV_SCRIPT_NAMES) {
|
||||
for (const scriptName of DEV_SERVER_SCRIPT_NAMES) {
|
||||
const command = scripts[scriptName];
|
||||
if (typeof command === "string" && command.trim().length > 0) {
|
||||
output.push({ name: scriptName, command: command.trim() });
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { badRequest, conflict, ApiError, sendErrorResponse } from "./api-error.js";
|
||||
import { detectDevServerScripts } from "./dev-server-detect.js";
|
||||
import { loadDevServerStore, resetDevServerStore, type DevServerStore } from "./dev-server-store.js";
|
||||
import {
|
||||
loadDevServerStore,
|
||||
resetDevServerStore,
|
||||
type DevServerConfig,
|
||||
type DevServerStore,
|
||||
} from "./dev-server-store.js";
|
||||
import { DevServerProcessManager } from "./dev-server-process.js";
|
||||
|
||||
export interface DevServerRouterOptions {
|
||||
@@ -43,6 +48,96 @@ function writeSSE(res: Response, chunk: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const DEV_SERVER_CONFIG_FIELDS: Array<keyof DevServerConfig> = [
|
||||
"selectedScript",
|
||||
"selectedSource",
|
||||
"selectedCommand",
|
||||
"previewUrlOverride",
|
||||
"detectedPreviewUrl",
|
||||
"selectedAt",
|
||||
];
|
||||
|
||||
function normalizeNullableStringField(
|
||||
raw: unknown,
|
||||
fieldName: keyof DevServerConfig,
|
||||
options: { requiredNonEmpty?: boolean; requireHttpUrl?: boolean } = {},
|
||||
): string | null {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof raw !== "string") {
|
||||
throw badRequest(`${fieldName} must be a string or null`);
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (options.requiredNonEmpty && trimmed.length === 0) {
|
||||
throw badRequest(`${fieldName} must be a non-empty string when provided`);
|
||||
}
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.requireHttpUrl && !trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
|
||||
throw badRequest(`${fieldName} must start with http:// or https://`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseConfigUpdateBody(body: unknown): Partial<DevServerConfig> {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw badRequest("Request body must be a JSON object");
|
||||
}
|
||||
|
||||
const source = body as Record<string, unknown>;
|
||||
const partial: Partial<DevServerConfig> = {};
|
||||
|
||||
for (const field of DEV_SERVER_CONFIG_FIELDS) {
|
||||
if (Object.hasOwn(source, field)) {
|
||||
partial[field] = source[field] as DevServerConfig[typeof field];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(partial).length === 0) {
|
||||
throw badRequest("At least one dev server config field is required");
|
||||
}
|
||||
|
||||
if (Object.hasOwn(partial, "selectedScript")) {
|
||||
partial.selectedScript = normalizeNullableStringField(partial.selectedScript, "selectedScript", {
|
||||
requiredNonEmpty: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.hasOwn(partial, "selectedSource")) {
|
||||
partial.selectedSource = normalizeNullableStringField(partial.selectedSource, "selectedSource");
|
||||
}
|
||||
|
||||
if (Object.hasOwn(partial, "selectedCommand")) {
|
||||
partial.selectedCommand = normalizeNullableStringField(partial.selectedCommand, "selectedCommand");
|
||||
}
|
||||
|
||||
if (Object.hasOwn(partial, "previewUrlOverride")) {
|
||||
partial.previewUrlOverride = normalizeNullableStringField(partial.previewUrlOverride, "previewUrlOverride", {
|
||||
requireHttpUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.hasOwn(partial, "detectedPreviewUrl")) {
|
||||
partial.detectedPreviewUrl = normalizeNullableStringField(partial.detectedPreviewUrl, "detectedPreviewUrl", {
|
||||
requireHttpUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.hasOwn(partial, "selectedAt")) {
|
||||
partial.selectedAt = normalizeNullableStringField(partial.selectedAt, "selectedAt");
|
||||
}
|
||||
|
||||
return partial;
|
||||
}
|
||||
|
||||
export function createDevServerRouter(options: DevServerRouterOptions): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -56,6 +151,38 @@ export function createDevServerRouter(options: DevServerRouterOptions): Router {
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/config", async (_req, res) => {
|
||||
try {
|
||||
const { store } = await getRuntime(options.projectRoot);
|
||||
res.json(store.getConfig());
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
sendErrorResponse(res, error.statusCode, error.message, { details: error.details });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "Failed to load dev server config";
|
||||
sendErrorResponse(res, 500, message);
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/config", async (req, res) => {
|
||||
try {
|
||||
const partial = parseConfigUpdateBody(req.body);
|
||||
const { store } = await getRuntime(options.projectRoot);
|
||||
const updated = await store.updateConfig(partial);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
sendErrorResponse(res, error.statusCode, error.message, { details: error.details });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "Failed to update dev server config";
|
||||
sendErrorResponse(res, 500, message);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/status", async (_req, res) => {
|
||||
try {
|
||||
const { store, manager } = await getRuntime(options.projectRoot);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
export type DevServerStatus = "starting" | "running" | "stopped" | "failed";
|
||||
|
||||
@@ -36,6 +36,30 @@ export interface DevServerState {
|
||||
logHistory: string[];
|
||||
}
|
||||
|
||||
export interface DevServerConfig {
|
||||
/** Selected script name (e.g., "dev") */
|
||||
selectedScript: string | null;
|
||||
/** Source of the selected script ("root" or relative workspace path) */
|
||||
selectedSource: string | null;
|
||||
/** Full command string for the selected script */
|
||||
selectedCommand: string | null;
|
||||
/** Manual preview URL override (user-provided) */
|
||||
previewUrlOverride: string | null;
|
||||
/** Last auto-detected preview URL */
|
||||
detectedPreviewUrl: string | null;
|
||||
/** ISO timestamp of last selection */
|
||||
selectedAt: string | null;
|
||||
}
|
||||
|
||||
export const DEV_SERVER_CONFIG_DEFAULTS: DevServerConfig = {
|
||||
selectedScript: null,
|
||||
selectedSource: null,
|
||||
selectedCommand: null,
|
||||
previewUrlOverride: null,
|
||||
detectedPreviewUrl: null,
|
||||
selectedAt: null,
|
||||
};
|
||||
|
||||
export const DEV_SERVER_LOG_MAX_LINES = 500;
|
||||
|
||||
export const DEV_SERVER_DEFAULT_STATE = (): DevServerState => ({
|
||||
@@ -48,7 +72,8 @@ export const DEV_SERVER_DEFAULT_STATE = (): DevServerState => ({
|
||||
});
|
||||
|
||||
interface DevServerStoreFile {
|
||||
state: DevServerState;
|
||||
state?: Partial<DevServerState>;
|
||||
config?: Partial<DevServerConfig>;
|
||||
}
|
||||
|
||||
function devServerFilePath(projectDir: string): string {
|
||||
@@ -81,9 +106,30 @@ function normalizeState(candidate: Partial<DevServerState> | null | undefined):
|
||||
return state;
|
||||
}
|
||||
|
||||
function normalizeStringOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeConfig(candidate: Partial<DevServerConfig> | null | undefined): DevServerConfig {
|
||||
return {
|
||||
selectedScript: normalizeStringOrNull(candidate?.selectedScript),
|
||||
selectedSource: normalizeStringOrNull(candidate?.selectedSource),
|
||||
selectedCommand: normalizeStringOrNull(candidate?.selectedCommand),
|
||||
previewUrlOverride: normalizeStringOrNull(candidate?.previewUrlOverride),
|
||||
detectedPreviewUrl: normalizeStringOrNull(candidate?.detectedPreviewUrl),
|
||||
selectedAt: normalizeStringOrNull(candidate?.selectedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export class DevServerStore {
|
||||
private readonly filePath: string;
|
||||
private state: DevServerState = DEV_SERVER_DEFAULT_STATE();
|
||||
private config: DevServerConfig = { ...DEV_SERVER_CONFIG_DEFAULTS };
|
||||
|
||||
constructor(projectDir: string) {
|
||||
this.filePath = devServerFilePath(projectDir);
|
||||
@@ -94,20 +140,26 @@ export class DevServerStore {
|
||||
const content = await readFile(this.filePath, "utf-8");
|
||||
const parsed = JSON.parse(content) as Partial<DevServerStoreFile>;
|
||||
this.state = normalizeState(parsed?.state);
|
||||
this.config = normalizeConfig(parsed?.config);
|
||||
} catch {
|
||||
this.state = DEV_SERVER_DEFAULT_STATE();
|
||||
this.config = { ...DEV_SERVER_CONFIG_DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
const dir = this.filePath.substring(0, this.filePath.lastIndexOf("/"));
|
||||
const dir = dirname(this.filePath);
|
||||
try {
|
||||
await access(dir);
|
||||
} catch {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const payload: DevServerStoreFile = { state: this.state };
|
||||
const payload: DevServerStoreFile = {
|
||||
state: this.state,
|
||||
config: this.config,
|
||||
};
|
||||
|
||||
await writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
@@ -129,6 +181,26 @@ export class DevServerStore {
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
getConfig(): DevServerConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
async saveConfig(config: DevServerConfig): Promise<DevServerConfig> {
|
||||
this.config = normalizeConfig(config);
|
||||
await this.save();
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
async updateConfig(partial: Partial<DevServerConfig>): Promise<DevServerConfig> {
|
||||
this.config = normalizeConfig({
|
||||
...this.config,
|
||||
...partial,
|
||||
});
|
||||
|
||||
await this.save();
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
async appendLog(line: string): Promise<void> {
|
||||
this.state.logHistory.push(line);
|
||||
if (this.state.logHistory.length > DEV_SERVER_LOG_MAX_LINES) {
|
||||
|
||||
Reference in New Issue
Block a user