feat(FN-2185): add session-based dev server API helpers
- Add canonical DevServer session types for commands, runtime state, logs, and preview metadata - Introduce session-scoped API helpers for list/create/get/start/stop/restart/delete operations under /api/devserver - Add log history, preview URL, command detection, and SSE stream URL helpers for session-based endpoints - Preserve migration compatibility by falling back to legacy /api/dev-server endpoints and normalizing legacy responses
This commit is contained in:
@@ -2338,6 +2338,499 @@ export function getDevServerLogsStreamUrl(projectId?: string): string {
|
||||
return buildApiUrl(withProjectId("/dev-server/logs/stream", projectId));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Session-based DevServer API (FN-2184 / FN-2185)
|
||||
// Target /api/devserver/* with fallback to /api/dev-server/* for migration safety
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Canonical session-based DevServer types.
|
||||
* These align with the new session model introduced in FN-2184.
|
||||
*/
|
||||
|
||||
// Detected dev server command (result of detectDevServerCommands)
|
||||
export interface DetectedDevServerCommand {
|
||||
name: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
scriptName: string;
|
||||
packagePath: string;
|
||||
framework?: string;
|
||||
}
|
||||
|
||||
// Dev server log entry format
|
||||
export interface DevServerLogEntry {
|
||||
timestamp: string;
|
||||
stream: "stdout" | "stderr";
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Preview URL response from backend
|
||||
export interface DevServerPreviewResponse {
|
||||
url: string | null;
|
||||
source: "auto" | "manual" | null;
|
||||
}
|
||||
|
||||
// Dev server runtime info (process details)
|
||||
export interface DevServerRuntime {
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
exitCode?: number;
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
// Dev server configuration (saved settings)
|
||||
export interface DevServerSessionConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
// Full DevServer session combining config, status, runtime, and logs
|
||||
export interface DevServerSession {
|
||||
config: DevServerSessionConfig;
|
||||
status: "stopped" | "starting" | "running" | "failed" | "stopping";
|
||||
runtime?: DevServerRuntime;
|
||||
previewUrl?: string;
|
||||
logHistory: DevServerLogEntry[];
|
||||
}
|
||||
|
||||
// Options for fetching log history
|
||||
export interface FetchDevServerLogsOptions {
|
||||
maxLines?: number;
|
||||
offset?: number;
|
||||
lastEventId?: number;
|
||||
}
|
||||
|
||||
// Backend response shape for log history
|
||||
interface BackendSessionLogResponse {
|
||||
lines?: DevServerLogEntry[];
|
||||
totalLines?: number;
|
||||
}
|
||||
|
||||
// Backend response for preview endpoint
|
||||
interface BackendPreviewResponse {
|
||||
url?: string | null;
|
||||
source?: string | null;
|
||||
}
|
||||
|
||||
// Backend response for list sessions
|
||||
interface BackendSessionsListResponse {
|
||||
sessions?: DevServerSession[];
|
||||
}
|
||||
|
||||
// Backend response for detect commands
|
||||
interface BackendDetectCommandsResponse {
|
||||
candidates?: DetectedDevServerCommand[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all dev server sessions.
|
||||
* Targets /api/devserver with fallback to /api/dev-server (legacy compatibility).
|
||||
*/
|
||||
export async function fetchDevServers(projectId?: string): Promise<DevServerSession[]> {
|
||||
try {
|
||||
const response = await api<BackendSessionsListResponse>(withProjectId("/devserver", projectId));
|
||||
return response.sessions ?? [];
|
||||
} catch {
|
||||
// Fallback: try to get the legacy single-server state and wrap it in session format
|
||||
try {
|
||||
const legacy = await fetchDevServerStatus(projectId);
|
||||
// Convert legacy state to session format
|
||||
const session: DevServerSession = {
|
||||
config: {
|
||||
id: legacy.id ?? "default",
|
||||
name: legacy.name ?? "Dev Server",
|
||||
command: legacy.command ?? "",
|
||||
cwd: legacy.cwd ?? ".",
|
||||
},
|
||||
status: legacy.status,
|
||||
runtime: legacy.pid
|
||||
? {
|
||||
pid: legacy.pid,
|
||||
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
||||
exitCode: legacy.exitCode ?? undefined,
|
||||
previewUrl: legacy.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
||||
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text, i) => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
||||
text: text.replace(/^\[stderr\]\s*/, ""),
|
||||
})),
|
||||
};
|
||||
return [session];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new dev server session.
|
||||
* Targets /api/devserver with fallback to /api/dev-server/start (legacy compatibility).
|
||||
*/
|
||||
export async function createDevServer(
|
||||
data: { command: string; cwd?: string; name?: string; env?: Record<string, string> },
|
||||
projectId?: string,
|
||||
): Promise<DevServerSession> {
|
||||
const body = {
|
||||
command: data.command,
|
||||
cwd: data.cwd ?? ".",
|
||||
name: data.name,
|
||||
env: data.env,
|
||||
};
|
||||
|
||||
try {
|
||||
return await api<DevServerSession>(withProjectId("/devserver", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
// Fallback: use legacy start endpoint
|
||||
const legacy = await startDevServer({ command: data.command, cwd: data.cwd }, projectId);
|
||||
return {
|
||||
config: {
|
||||
id: legacy.id ?? "default",
|
||||
name: legacy.name ?? data.name ?? "Dev Server",
|
||||
command: legacy.command,
|
||||
cwd: legacy.cwd ?? data.cwd ?? ".",
|
||||
},
|
||||
status: legacy.status,
|
||||
runtime: legacy.pid
|
||||
? {
|
||||
pid: legacy.pid,
|
||||
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
||||
exitCode: legacy.exitCode ?? undefined,
|
||||
previewUrl: legacy.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
||||
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
||||
text: text.replace(/^\[stderr\]\s*/, ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific dev server session by ID.
|
||||
* Targets /api/devserver/:id with fallback to /api/dev-server/status (legacy compatibility).
|
||||
*/
|
||||
export async function fetchDevServer(id: string, projectId?: string): Promise<DevServerSession | null> {
|
||||
try {
|
||||
return await api<DevServerSession>(withProjectId(`/devserver/${encodeURIComponent(id)}`, projectId));
|
||||
} catch {
|
||||
// Fallback: try legacy status endpoint (single-server model)
|
||||
try {
|
||||
const legacy = await fetchDevServerStatus(projectId);
|
||||
// If no ID or ID matches default, return legacy state as session
|
||||
if (!id || id === "default" || id === legacy.id) {
|
||||
return {
|
||||
config: {
|
||||
id: legacy.id ?? "default",
|
||||
name: legacy.name ?? "Dev Server",
|
||||
command: legacy.command ?? "",
|
||||
cwd: legacy.cwd ?? ".",
|
||||
},
|
||||
status: legacy.status,
|
||||
runtime: legacy.pid
|
||||
? {
|
||||
pid: legacy.pid,
|
||||
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
||||
exitCode: legacy.exitCode ?? undefined,
|
||||
previewUrl: legacy.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
||||
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
||||
text: text.replace(/^\[stderr\]\s*/, ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a specific dev server by ID.
|
||||
* Targets /api/devserver/:id/start with fallback to /api/dev-server/start (legacy compatibility).
|
||||
*/
|
||||
export async function startDevServerById(id: string, projectId?: string): Promise<DevServerSession> {
|
||||
try {
|
||||
return await api<DevServerSession>(withProjectId(`/devserver/${encodeURIComponent(id)}/start`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
} catch {
|
||||
// Fallback: use legacy start endpoint (single-server model)
|
||||
const legacy = await startDevServer({ command: "" }, projectId);
|
||||
return {
|
||||
config: {
|
||||
id: legacy.id ?? id,
|
||||
name: legacy.name ?? "Dev Server",
|
||||
command: legacy.command ?? "",
|
||||
cwd: legacy.cwd ?? ".",
|
||||
},
|
||||
status: legacy.status,
|
||||
runtime: legacy.pid
|
||||
? {
|
||||
pid: legacy.pid,
|
||||
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
||||
exitCode: legacy.exitCode ?? undefined,
|
||||
previewUrl: legacy.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
||||
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
||||
text: text.replace(/^\[stderr\]\s*/, ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a specific dev server by ID.
|
||||
* Targets /api/devserver/:id/stop with fallback to /api/dev-server/stop (legacy compatibility).
|
||||
*/
|
||||
export async function stopDevServerById(id: string, projectId?: string): Promise<DevServerSession> {
|
||||
try {
|
||||
return await api<DevServerSession>(withProjectId(`/devserver/${encodeURIComponent(id)}/stop`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
} catch {
|
||||
// Fallback: use legacy stop endpoint
|
||||
const legacy = await stopDevServer(projectId);
|
||||
return {
|
||||
config: {
|
||||
id: legacy.id ?? id,
|
||||
name: legacy.name ?? "Dev Server",
|
||||
command: legacy.command ?? "",
|
||||
cwd: legacy.cwd ?? ".",
|
||||
},
|
||||
status: legacy.status,
|
||||
runtime: legacy.pid
|
||||
? {
|
||||
pid: legacy.pid,
|
||||
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
||||
exitCode: legacy.exitCode ?? undefined,
|
||||
previewUrl: legacy.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
||||
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
||||
text: text.replace(/^\[stderr\]\s*/, ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a specific dev server by ID.
|
||||
* Targets /api/devserver/:id/restart with fallback to /api/dev-server/restart (legacy compatibility).
|
||||
*/
|
||||
export async function restartDevServerById(id: string, projectId?: string): Promise<DevServerSession> {
|
||||
try {
|
||||
return await api<DevServerSession>(withProjectId(`/devserver/${encodeURIComponent(id)}/restart`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
} catch {
|
||||
// Fallback: use legacy restart endpoint
|
||||
const legacy = await restartDevServer(projectId);
|
||||
return {
|
||||
config: {
|
||||
id: legacy.id ?? id,
|
||||
name: legacy.name ?? "Dev Server",
|
||||
command: legacy.command ?? "",
|
||||
cwd: legacy.cwd ?? ".",
|
||||
},
|
||||
status: legacy.status,
|
||||
runtime: legacy.pid
|
||||
? {
|
||||
pid: legacy.pid,
|
||||
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
||||
exitCode: legacy.exitCode ?? undefined,
|
||||
previewUrl: legacy.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
||||
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
||||
text: text.replace(/^\[stderr\]\s*/, ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific dev server by ID.
|
||||
* Targets /api/devserver/:id with fallback (no legacy equivalent).
|
||||
*/
|
||||
export async function deleteDevServer(id: string, projectId?: string): Promise<void> {
|
||||
try {
|
||||
await api<void>(withProjectId(`/devserver/${encodeURIComponent(id)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch {
|
||||
// No fallback for delete in legacy API (single-server model)
|
||||
// Silently ignore - deletion may not be supported in legacy mode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch logs for a specific dev server by ID.
|
||||
* Targets /api/devserver/:id/logs with fallback to /api/dev-server/logs/history (legacy compatibility).
|
||||
*/
|
||||
export async function fetchDevServerLogs(
|
||||
id: string,
|
||||
opts: FetchDevServerLogsOptions = {},
|
||||
projectId?: string,
|
||||
): Promise<{ lines: DevServerLogEntry[]; totalLines: number }> {
|
||||
const query = new URLSearchParams();
|
||||
if (typeof opts.maxLines === "number" && Number.isFinite(opts.maxLines)) {
|
||||
query.set("maxLines", String(Math.max(1, Math.floor(opts.maxLines))));
|
||||
}
|
||||
if (typeof opts.offset === "number" && Number.isFinite(opts.offset)) {
|
||||
query.set("offset", String(Math.max(0, Math.floor(opts.offset))));
|
||||
}
|
||||
if (typeof opts.lastEventId === "number" && Number.isFinite(opts.lastEventId)) {
|
||||
query.set("lastEventId", String(Math.max(0, Math.floor(opts.lastEventId))));
|
||||
}
|
||||
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
||||
|
||||
try {
|
||||
const response = await api<BackendSessionLogResponse>(
|
||||
withProjectId(`/devserver/${encodeURIComponent(id)}/logs${suffix}`, projectId),
|
||||
);
|
||||
return {
|
||||
lines: response.lines ?? [],
|
||||
totalLines: response.totalLines ?? response.lines?.length ?? 0,
|
||||
};
|
||||
} catch {
|
||||
// Fallback: use legacy log history endpoint
|
||||
try {
|
||||
const response = await fetchDevServerLogHistory(opts, projectId);
|
||||
return {
|
||||
lines: response.lines.map<DevServerLogEntry>((entry) => ({
|
||||
timestamp: entry.timestamp,
|
||||
stream: entry.stream,
|
||||
text: entry.text,
|
||||
})),
|
||||
totalLines: response.totalLines,
|
||||
};
|
||||
} catch {
|
||||
return { lines: [], totalLines: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch preview URL for a specific dev server by ID.
|
||||
* Targets /api/devserver/:id/preview with fallback to /api/dev-server/status (legacy compatibility).
|
||||
*/
|
||||
export async function fetchDevServerPreview(id: string, projectId?: string): Promise<DevServerPreviewResponse> {
|
||||
try {
|
||||
const response = await api<BackendPreviewResponse>(
|
||||
withProjectId(`/devserver/${encodeURIComponent(id)}/preview`, projectId),
|
||||
);
|
||||
return {
|
||||
url: response.url ?? null,
|
||||
source: (response.source as DevServerPreviewResponse["source"]) ?? null,
|
||||
};
|
||||
} catch {
|
||||
// Fallback: use legacy status endpoint
|
||||
try {
|
||||
const legacy = await fetchDevServerStatus(projectId);
|
||||
return {
|
||||
url: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl ?? null,
|
||||
source: legacy.manualUrl ? "manual" : "auto",
|
||||
};
|
||||
} catch {
|
||||
return { url: null, source: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preview URL for a specific dev server by ID.
|
||||
* Targets /api/devserver/:id/preview with fallback to /api/dev-server/preview-url (legacy compatibility).
|
||||
*/
|
||||
export async function setDevServerPreviewUrlById(
|
||||
id: string,
|
||||
url: string | null,
|
||||
projectId?: string,
|
||||
): Promise<DevServerPreviewResponse> {
|
||||
try {
|
||||
const response = await api<BackendPreviewResponse>(
|
||||
withProjectId(`/devserver/${encodeURIComponent(id)}/preview`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
},
|
||||
);
|
||||
return {
|
||||
url: response.url ?? null,
|
||||
source: (response.source as DevServerPreviewResponse["source"]) ?? null,
|
||||
};
|
||||
} catch {
|
||||
// Fallback: use legacy preview URL endpoint
|
||||
const legacy = await setDevServerPreviewUrl({ url }, projectId);
|
||||
return {
|
||||
url: legacy.previewUrl ?? legacy.manualUrl ?? null,
|
||||
source: "manual",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect available dev server commands.
|
||||
* Targets /api/devserver/detect with fallback to /api/dev-server/detect (legacy compatibility).
|
||||
*/
|
||||
export async function detectDevServerCommands(projectId?: string): Promise<DetectedDevServerCommand[]> {
|
||||
try {
|
||||
const response = await api<BackendDetectCommandsResponse>(withProjectId("/devserver/detect", projectId));
|
||||
return response.candidates ?? [];
|
||||
} catch {
|
||||
// Fallback: use legacy detect endpoint
|
||||
try {
|
||||
const legacy = await fetchDevServerCandidates(projectId);
|
||||
return legacy.map<DetectedDevServerCommand>((candidate) => ({
|
||||
name: candidate.name,
|
||||
command: candidate.command,
|
||||
cwd: candidate.cwd,
|
||||
scriptName: candidate.scriptName,
|
||||
packagePath: candidate.packagePath,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SSE stream URL for a specific dev server session's logs.
|
||||
* Targets /api/devserver/:id/logs/stream with fallback to /api/dev-server/logs/stream (legacy compatibility).
|
||||
*/
|
||||
export function getDevServerSessionLogsStreamUrl(id: string, projectId?: string): string {
|
||||
// Try new session-scoped endpoint first
|
||||
return buildApiUrl(withProjectId(`/devserver/${encodeURIComponent(id)}/logs/stream`, projectId));
|
||||
}
|
||||
|
||||
function startKeepAlive(
|
||||
sessionId: string,
|
||||
projectId?: string,
|
||||
|
||||
Reference in New Issue
Block a user