mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
1258 lines
46 KiB
TypeScript
1258 lines
46 KiB
TypeScript
import "server-only";
|
|
|
|
import { getPublicEnvVar } from "@/lib/env";
|
|
import { hexclaveAppInternalsSymbol } from "@/lib/hexclave-app-internals";
|
|
import { AdminOwnedProject, StackClientApp } from "@hexclave/next";
|
|
import { Config, override } from "@hexclave/shared/dist/config/format";
|
|
import { ProjectOnboardingStatus } from "@hexclave/shared/dist/schema-fields";
|
|
import { AccessToken } from "@hexclave/shared/dist/sessions";
|
|
import { errorToNiceString } from "@hexclave/shared/dist/utils/errors";
|
|
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
|
|
import { randomUUID } from "crypto";
|
|
import { appendFileSync, watch, type FSWatcher } from "fs";
|
|
import { basename, dirname } from "path";
|
|
import { peekRemoteDevelopmentEnvironmentBrowserSecretConfirmationCodeForCli } from "./browser-secret";
|
|
import { formatConfigSyncErrorForCli } from "./config-sync-error-format";
|
|
import {
|
|
ensureConfigFileExists,
|
|
readConfigFile,
|
|
replaceConfigObject,
|
|
resolveConfigFilePath,
|
|
sha256String,
|
|
updateConfigObject,
|
|
} from "./config-file";
|
|
import { assertRemoteDevelopmentEnvironmentEnabled } from "./env";
|
|
import {
|
|
RemoteDevelopmentEnvironmentProject,
|
|
readRemoteDevelopmentEnvironmentState,
|
|
updateRemoteDevelopmentEnvironmentState,
|
|
} from "./state";
|
|
|
|
const SESSION_TTL_MS = 25_000;
|
|
const FIRST_HEARTBEAT_TTL_MS = 5 * 60_000;
|
|
const STARTUP_EMPTY_SESSION_GRACE_MS = 20_000;
|
|
const SYNC_DEBOUNCE_MS = 500;
|
|
const CONFIG_SYNC_FORMAT_VERSION = 2;
|
|
const LOG_PREFIX = "[Stack RDE]";
|
|
const RDE_DASHBOARD_LOG_PATH_ENV_VAR = "HEXCLAVE_RDE_DASHBOARD_LOG_PATH";
|
|
const CONFIG_SYNC_DUPLICATE_EVENT_SUPPRESSION_MS = 2_000;
|
|
const CONFIG_UPDATE_LOG_PATH_LIMIT = 40;
|
|
|
|
export class RemoteDevelopmentEnvironmentApiUnavailableError extends Error {
|
|
constructor(apiBaseUrl: string, cause: unknown) {
|
|
super(`Could not connect to the Hexclave API at ${apiBaseUrl}. Make sure the backend for this development environment is running and reachable.`, { cause });
|
|
this.name = "RemoteDevelopmentEnvironmentApiUnavailableError";
|
|
}
|
|
}
|
|
|
|
type ActiveSession = {
|
|
configFilePath: string,
|
|
lastHeartbeatMs: number,
|
|
receivedFirstHeartbeat: boolean,
|
|
lastDeliveredConfigSyncEventId: number,
|
|
};
|
|
|
|
type ConfigSyncEventBase = {
|
|
id: number,
|
|
configFilePath: string,
|
|
createdAtMillis: number,
|
|
};
|
|
|
|
type ConfigSyncEvent = ConfigSyncEventBase & ({
|
|
status: "success",
|
|
} | {
|
|
status: "error",
|
|
errorMessage: string,
|
|
});
|
|
|
|
type RemoteDevelopmentEnvironmentConfigSyncEventBase = {
|
|
configFilePath: string,
|
|
createdAtMillis: number,
|
|
};
|
|
|
|
export type RemoteDevelopmentEnvironmentConfigSyncEvent = RemoteDevelopmentEnvironmentConfigSyncEventBase & ({
|
|
status: "success",
|
|
} | {
|
|
status: "error",
|
|
errorMessage: string,
|
|
});
|
|
|
|
type RemoteDevelopmentEnvironmentDebugSession = {
|
|
sessionId: string,
|
|
configFilePath: string,
|
|
lastHeartbeatAgeMs: number,
|
|
ttlMs: number,
|
|
expiresInMs: number,
|
|
receivedFirstHeartbeat: boolean,
|
|
};
|
|
|
|
type RemoteDevelopmentEnvironmentDebugSnapshot = {
|
|
uptimeMs: number,
|
|
shutdownTimerStarted: boolean,
|
|
activeOperations: number,
|
|
hasClosedSession: boolean,
|
|
sessions: RemoteDevelopmentEnvironmentDebugSession[],
|
|
watchedConfigFiles: string[],
|
|
pendingSyncConfigFiles: string[],
|
|
syncErrors: { configFilePath: string, error: string }[],
|
|
pendingConfigSyncEvents: {
|
|
id: number,
|
|
configFilePath: string,
|
|
status: "success" | "error",
|
|
errorMessage?: string,
|
|
createdAgoMs: number,
|
|
}[],
|
|
synchronouslyUpdatingConfigFiles: string[],
|
|
localDashboards: {
|
|
port: number,
|
|
pid: number,
|
|
startedAgoMs: number,
|
|
logPath?: string,
|
|
}[],
|
|
pendingBrowserSecretConfirmationCodes: {
|
|
port: string,
|
|
code: string,
|
|
expiresInMs: number,
|
|
updatedAgoMs: number,
|
|
}[],
|
|
projects: {
|
|
configFilePath: string,
|
|
projectId: string,
|
|
teamId: string,
|
|
apiBaseUrl: string,
|
|
updatedAgoMs: number,
|
|
hasLastSyncedConfigHash: boolean,
|
|
}[],
|
|
};
|
|
|
|
type RemoteDevelopmentEnvironmentGlobals = {
|
|
sessions: Map<string, ActiveSession>,
|
|
watchers: Map<string, FSWatcher>,
|
|
syncTimers: Map<string, NodeJS.Timeout>,
|
|
activeConfigSyncs: Map<string, Promise<void>>,
|
|
syncErrors: Map<string, Error>,
|
|
configSyncEvents: ConfigSyncEvent[],
|
|
lastConfigSyncEventByConfigFile: Map<string, ConfigSyncEvent>,
|
|
nextConfigSyncEventId: number,
|
|
synchronouslyUpdatingConfigFiles: Set<string>,
|
|
shutdownTimerStarted: boolean,
|
|
startedAtMs: number,
|
|
activeOperations: number,
|
|
hasClosedSession: boolean,
|
|
};
|
|
|
|
type HexclaveAppRequestInternals = {
|
|
sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise<Response>,
|
|
};
|
|
|
|
const globals = globalThis as typeof globalThis & {
|
|
__stackRemoteDevelopmentEnvironment?: RemoteDevelopmentEnvironmentGlobals,
|
|
};
|
|
|
|
function getGlobals(): RemoteDevelopmentEnvironmentGlobals {
|
|
assertRemoteDevelopmentEnvironmentEnabled();
|
|
globals.__stackRemoteDevelopmentEnvironment ??= {
|
|
sessions: new Map(),
|
|
watchers: new Map(),
|
|
syncTimers: new Map(),
|
|
activeConfigSyncs: new Map(),
|
|
syncErrors: new Map(),
|
|
configSyncEvents: [],
|
|
lastConfigSyncEventByConfigFile: new Map(),
|
|
nextConfigSyncEventId: 1,
|
|
synchronouslyUpdatingConfigFiles: new Set(),
|
|
shutdownTimerStarted: false,
|
|
startedAtMs: performance.now(),
|
|
activeOperations: 0,
|
|
hasClosedSession: false,
|
|
};
|
|
return globals.__stackRemoteDevelopmentEnvironment;
|
|
}
|
|
|
|
function writeRemoteDevelopmentEnvironmentLogLine(line: string): boolean {
|
|
const logPath = process.env[RDE_DASHBOARD_LOG_PATH_ENV_VAR];
|
|
if (logPath == null || logPath.length === 0) {
|
|
return false;
|
|
}
|
|
try {
|
|
appendFileSync(logPath, `${line}\n`);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function formatRemoteDevelopmentEnvironmentLogLine(message: string, details?: Record<string, unknown>): string {
|
|
const prefix = `[${new Date().toISOString()}] ${LOG_PREFIX}`;
|
|
if (details == null) {
|
|
return `${prefix} ${message}`;
|
|
}
|
|
return `${prefix} ${message} ${JSON.stringify(details)}`;
|
|
}
|
|
|
|
function logRemoteDevelopmentEnvironment(message: string, details?: Record<string, unknown>): void {
|
|
const line = formatRemoteDevelopmentEnvironmentLogLine(message, details);
|
|
if (writeRemoteDevelopmentEnvironmentLogLine(line)) {
|
|
return;
|
|
}
|
|
console.log(line);
|
|
}
|
|
|
|
function warnRemoteDevelopmentEnvironment(message: string, details?: Record<string, unknown>): void {
|
|
const line = formatRemoteDevelopmentEnvironmentLogLine(message, details);
|
|
if (writeRemoteDevelopmentEnvironmentLogLine(line)) {
|
|
return;
|
|
}
|
|
console.warn(line);
|
|
}
|
|
|
|
function formatErrorForRemoteDevelopmentEnvironmentLog(error: unknown): Record<string, unknown> {
|
|
if (error instanceof Error) {
|
|
const cause = Reflect.get(error, "cause");
|
|
return {
|
|
errorName: error.name,
|
|
errorMessage: error.message,
|
|
errorStack: error.stack,
|
|
...(cause == null ? {} : { errorCause: errorToNiceString(cause) }),
|
|
};
|
|
}
|
|
return {
|
|
errorMessage: errorToNiceString(error),
|
|
};
|
|
}
|
|
|
|
function collectConfigUpdatePaths(config: Config, prefix: string, paths: string[]): void {
|
|
for (const [key, value] of Object.entries(config)) {
|
|
if (value === undefined) continue;
|
|
const path = prefix.length === 0 ? key : `${prefix}.${key}`;
|
|
if (value != null && typeof value === "object" && !Array.isArray(value)) {
|
|
collectConfigUpdatePaths(value, path, paths);
|
|
} else {
|
|
paths.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
function summarizeConfigUpdateForLog(configUpdate: Config): Record<string, unknown> {
|
|
const configUpdatePaths: string[] = [];
|
|
collectConfigUpdatePaths(configUpdate, "", configUpdatePaths);
|
|
configUpdatePaths.sort();
|
|
return {
|
|
configUpdateTopLevelKeys: Object.keys(configUpdate).sort(),
|
|
configUpdatePathCount: configUpdatePaths.length,
|
|
configUpdatePaths: configUpdatePaths.slice(0, CONFIG_UPDATE_LOG_PATH_LIMIT),
|
|
configUpdatePathsTruncated: configUpdatePaths.length > CONFIG_UPDATE_LOG_PATH_LIMIT,
|
|
};
|
|
}
|
|
|
|
function configSyncEventsMatchForCliDeduplication(a: ConfigSyncEvent, b: { status: "success" } | { status: "error", errorMessage: string }): boolean {
|
|
if (a.status !== b.status) {
|
|
return false;
|
|
}
|
|
if (a.status === "success") {
|
|
return true;
|
|
}
|
|
if (b.status !== "error") {
|
|
return false;
|
|
}
|
|
return a.errorMessage === b.errorMessage;
|
|
}
|
|
|
|
function recordConfigSyncEvent(configFilePath: string, event: { status: "success" } | { status: "error", errorMessage: string }): void {
|
|
const state = getGlobals();
|
|
const createdAtMillis = Date.now();
|
|
const lastEvent = state.lastConfigSyncEventByConfigFile.get(configFilePath);
|
|
const isDuplicate = lastEvent != null &&
|
|
configSyncEventsMatchForCliDeduplication(lastEvent, event) &&
|
|
createdAtMillis - lastEvent.createdAtMillis < CONFIG_SYNC_DUPLICATE_EVENT_SUPPRESSION_MS;
|
|
if (isDuplicate) {
|
|
logRemoteDevelopmentEnvironment("Suppressing duplicate config sync CLI notification", {
|
|
configFilePath,
|
|
status: event.status,
|
|
duplicateWindowMs: CONFIG_SYNC_DUPLICATE_EVENT_SUPPRESSION_MS,
|
|
});
|
|
return;
|
|
}
|
|
const baseEvent = {
|
|
id: state.nextConfigSyncEventId,
|
|
configFilePath,
|
|
createdAtMillis,
|
|
};
|
|
const configSyncEvent: ConfigSyncEvent = event.status === "success"
|
|
? { ...baseEvent, status: "success" }
|
|
: { ...baseEvent, status: "error", errorMessage: event.errorMessage };
|
|
state.nextConfigSyncEventId += 1;
|
|
state.configSyncEvents.push(configSyncEvent);
|
|
state.lastConfigSyncEventByConfigFile.set(configFilePath, configSyncEvent);
|
|
if (state.configSyncEvents.length > 100) {
|
|
state.configSyncEvents.splice(0, state.configSyncEvents.length - 100);
|
|
}
|
|
}
|
|
|
|
function drainConfigSyncEventsForSession(session: ActiveSession): RemoteDevelopmentEnvironmentConfigSyncEvent[] {
|
|
const state = getGlobals();
|
|
const pendingEvents = state.configSyncEvents.filter((event) => (
|
|
event.configFilePath === session.configFilePath &&
|
|
event.id > session.lastDeliveredConfigSyncEventId
|
|
));
|
|
if (pendingEvents.length === 0) {
|
|
return [];
|
|
}
|
|
session.lastDeliveredConfigSyncEventId = pendingEvents[pendingEvents.length - 1].id;
|
|
return pendingEvents.map((event) => event.status === "success"
|
|
? {
|
|
configFilePath: event.configFilePath,
|
|
status: "success",
|
|
createdAtMillis: event.createdAtMillis,
|
|
}
|
|
: {
|
|
configFilePath: event.configFilePath,
|
|
status: "error",
|
|
errorMessage: event.errorMessage,
|
|
createdAtMillis: event.createdAtMillis,
|
|
});
|
|
}
|
|
|
|
function errorLooksLikeApiConnectionFailure(error: unknown): boolean {
|
|
const message = errorToNiceString(error);
|
|
return (
|
|
message.includes("ECONNREFUSED")
|
|
|| message.includes("ECONNRESET")
|
|
|| message.includes("ETIMEDOUT")
|
|
|| message.includes("ENOTFOUND")
|
|
|| message.includes("fetch failed")
|
|
);
|
|
}
|
|
|
|
function throwApiUnavailableIfConnectionFailure(apiBaseUrl: string, error: unknown): never {
|
|
if (errorLooksLikeApiConnectionFailure(error)) {
|
|
throw new RemoteDevelopmentEnvironmentApiUnavailableError(apiBaseUrl, error);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
function isStackAppRequestInternals(value: unknown): value is HexclaveAppRequestInternals {
|
|
return (
|
|
value != null &&
|
|
typeof value === "object" &&
|
|
"sendRequest" in value &&
|
|
typeof value.sendRequest === "function"
|
|
);
|
|
}
|
|
|
|
function getStackAppRequestInternals(appValue: unknown): HexclaveAppRequestInternals {
|
|
if (appValue == null || typeof appValue !== "object") {
|
|
throw new Error("The Stack app instance is unavailable.");
|
|
}
|
|
|
|
const internals = Reflect.get(appValue, hexclaveAppInternalsSymbol);
|
|
if (!isStackAppRequestInternals(internals)) {
|
|
throw new Error("The Stack app cannot send remote development environment onboarding updates.");
|
|
}
|
|
|
|
return internals;
|
|
}
|
|
|
|
function beginRemoteDevelopmentEnvironmentOperation(name: string, details?: Record<string, unknown>): () => void {
|
|
const state = getGlobals();
|
|
const startedAtMs = performance.now();
|
|
state.activeOperations += 1;
|
|
logRemoteDevelopmentEnvironment(`Started ${name}`, {
|
|
...details,
|
|
activeOperations: state.activeOperations,
|
|
});
|
|
|
|
let ended = false;
|
|
return () => {
|
|
if (ended) return;
|
|
ended = true;
|
|
state.activeOperations -= 1;
|
|
logRemoteDevelopmentEnvironment(`Finished ${name}`, {
|
|
...details,
|
|
activeOperations: state.activeOperations,
|
|
elapsedMs: Math.round(performance.now() - startedAtMs),
|
|
});
|
|
};
|
|
}
|
|
|
|
function internalPublishableClientKey(): string {
|
|
const key = process.env.STACK_CLI_PUBLISHABLE_CLIENT_KEY ?? getPublicEnvVar("NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY");
|
|
if (key == null || key.length === 0) {
|
|
throw new Error("Missing internal publishable client key for remote development environment dashboard.");
|
|
}
|
|
return key;
|
|
}
|
|
|
|
function createInternalApp(apiBaseUrl: string, anonymousRefreshToken?: string) {
|
|
return new StackClientApp({
|
|
projectId: "internal",
|
|
publishableClientKey: internalPublishableClientKey(),
|
|
baseUrl: apiBaseUrl,
|
|
tokenStore: anonymousRefreshToken == null ? "memory" : { refreshToken: anonymousRefreshToken, accessToken: "" },
|
|
noAutomaticPrefetch: true,
|
|
});
|
|
}
|
|
|
|
function envVarsForProject(project: RemoteDevelopmentEnvironmentProject): Record<string, string> {
|
|
const brands = ["HEXCLAVE", "STACK"];
|
|
const publicPrefixes = ["", "NEXT_PUBLIC_", "VITE_", "EXPO_PUBLIC_"];
|
|
|
|
const publicValues: Record<string, string> = {
|
|
PROJECT_ID: project.projectId,
|
|
PUBLISHABLE_CLIENT_KEY: project.publishableClientKey,
|
|
API_URL: project.apiBaseUrl,
|
|
};
|
|
const secretValues: Record<string, string> = {
|
|
SECRET_SERVER_KEY: project.secretServerKey,
|
|
};
|
|
|
|
const env: Record<string, string> = {};
|
|
for (const brand of brands) {
|
|
for (const [name, value] of Object.entries(publicValues)) {
|
|
for (const prefix of publicPrefixes) {
|
|
env[`${prefix}${brand}_${name}`] = value;
|
|
}
|
|
}
|
|
for (const [name, value] of Object.entries(secretValues)) {
|
|
env[`${brand}_${name}`] = value;
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
async function getOrCreateProject(options: {
|
|
apiBaseUrl: string,
|
|
configFilePath: string,
|
|
anonymousRefreshToken?: string,
|
|
}): Promise<{ anonymousRefreshToken: string, project: RemoteDevelopmentEnvironmentProject }> {
|
|
logRemoteDevelopmentEnvironment("Ensuring development-environment project exists", {
|
|
apiBaseUrl: options.apiBaseUrl,
|
|
configFilePath: options.configFilePath,
|
|
hasExistingAnonymousSession: options.anonymousRefreshToken != null,
|
|
});
|
|
const app = createInternalApp(options.apiBaseUrl, options.anonymousRefreshToken);
|
|
const user = await app.getUser({ or: "anonymous" });
|
|
const authJson = await user.getAuthJson();
|
|
const anonymousRefreshToken = authJson.refreshToken ?? (() => {
|
|
throw new Error("Anonymous session did not return a refresh token.");
|
|
})();
|
|
|
|
const state = readRemoteDevelopmentEnvironmentState();
|
|
const storedProject = state.projectsByConfigPath[options.configFilePath];
|
|
const ownedProjects = await user.listOwnedProjects();
|
|
const existingProject = storedProject == null
|
|
? undefined
|
|
: ownedProjects.find((project) => project.id === storedProject.projectId);
|
|
if (storedProject != null && existingProject != null) {
|
|
const updatedProject = {
|
|
...storedProject,
|
|
apiBaseUrl: options.apiBaseUrl,
|
|
updatedAtMillis: Date.now(),
|
|
};
|
|
updateRemoteDevelopmentEnvironmentState((current) => ({
|
|
...current,
|
|
anonymousRefreshToken,
|
|
anonymousApiBaseUrl: options.apiBaseUrl,
|
|
projectsByConfigPath: {
|
|
...current.projectsByConfigPath,
|
|
[options.configFilePath]: updatedProject,
|
|
},
|
|
}));
|
|
logRemoteDevelopmentEnvironment("Reusing stored development-environment project", {
|
|
projectId: updatedProject.projectId,
|
|
teamId: updatedProject.teamId,
|
|
configFilePath: options.configFilePath,
|
|
});
|
|
return { anonymousRefreshToken, project: updatedProject };
|
|
}
|
|
|
|
const label = basename(dirname(options.configFilePath)) || "Project";
|
|
logRemoteDevelopmentEnvironment("Creating new development-environment team and project", {
|
|
label,
|
|
configFilePath: options.configFilePath,
|
|
});
|
|
const team = await user.createTeam({
|
|
displayName: `Development Environment: ${label}`,
|
|
});
|
|
const project = await user.createProject({
|
|
displayName: "Development Environment Project",
|
|
description: `Development environment for ${label}`,
|
|
teamId: team.id,
|
|
isProductionMode: false,
|
|
isDevelopmentEnvironment: true,
|
|
});
|
|
const key = await project.app.createInternalApiKey({
|
|
description: `Development environment key for ${label}`,
|
|
expiresAt: new Date("2099-12-31T23:59:59Z"),
|
|
hasPublishableClientKey: true,
|
|
hasSecretServerKey: true,
|
|
hasSuperSecretAdminKey: false,
|
|
});
|
|
if (key.publishableClientKey == null || key.secretServerKey == null) {
|
|
throw new Error("Development environment API key response did not include the expected keys.");
|
|
}
|
|
|
|
const mappedProject: RemoteDevelopmentEnvironmentProject = {
|
|
projectId: project.id,
|
|
teamId: team.id,
|
|
publishableClientKey: key.publishableClientKey,
|
|
secretServerKey: key.secretServerKey,
|
|
apiBaseUrl: options.apiBaseUrl,
|
|
updatedAtMillis: Date.now(),
|
|
};
|
|
logRemoteDevelopmentEnvironment("Created development-environment project", {
|
|
projectId: mappedProject.projectId,
|
|
teamId: mappedProject.teamId,
|
|
configFilePath: options.configFilePath,
|
|
});
|
|
updateRemoteDevelopmentEnvironmentState((current) => ({
|
|
...current,
|
|
anonymousRefreshToken,
|
|
anonymousApiBaseUrl: options.apiBaseUrl,
|
|
projectsByConfigPath: {
|
|
...current.projectsByConfigPath,
|
|
[options.configFilePath]: mappedProject,
|
|
},
|
|
}));
|
|
return { anonymousRefreshToken, project: mappedProject };
|
|
}
|
|
|
|
export async function getRemoteDevelopmentEnvironmentAccessToken(): Promise<{ accessToken: string, expiresAtMillis: number, issuedAtMillis: number, userId: string }> {
|
|
const state = readRemoteDevelopmentEnvironmentState();
|
|
if (state.anonymousRefreshToken == null) {
|
|
throw new Error("Remote development environment has no anonymous session yet.");
|
|
}
|
|
|
|
const apiBaseUrl = state.anonymousApiBaseUrl ?? Object.values(state.projectsByConfigPath)[0]?.apiBaseUrl;
|
|
if (apiBaseUrl == null) {
|
|
throw new Error("Remote development environment has no API base URL yet.");
|
|
}
|
|
|
|
const app = createInternalApp(apiBaseUrl, state.anonymousRefreshToken);
|
|
const user = await app.getUser({ or: "anonymous" });
|
|
const accessToken = (await user.getAuthJson()).accessToken ?? (() => {
|
|
throw new Error("Remote development environment anonymous session did not return an access token.");
|
|
})();
|
|
const parsedAccessToken = AccessToken.createIfValid(accessToken) ?? (() => {
|
|
throw new Error("Remote development environment anonymous session returned an invalid access token.");
|
|
})();
|
|
|
|
return {
|
|
accessToken,
|
|
expiresAtMillis: parsedAccessToken.expiresAt.getTime(),
|
|
issuedAtMillis: parsedAccessToken.issuedAt.getTime(),
|
|
userId: user.id,
|
|
};
|
|
}
|
|
|
|
async function syncRemoteDevelopmentEnvironmentOnboardingStatus(
|
|
project: AdminOwnedProject,
|
|
showOnboarding: boolean,
|
|
): Promise<ProjectOnboardingStatus> {
|
|
const onboardingStatus = showOnboarding && project.onboardingStatus === "completed"
|
|
? "config_choice"
|
|
: showOnboarding
|
|
? project.onboardingStatus
|
|
: "completed";
|
|
|
|
const body = showOnboarding
|
|
? { onboarding_status: onboardingStatus }
|
|
: { onboarding_status: onboardingStatus, onboarding_state: null };
|
|
const response = await getStackAppRequestInternals(project.app).sendRequest(
|
|
"/internal/projects/current",
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
},
|
|
"admin",
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to sync development-environment project onboarding status (${response.status}): ${await response.text()}`);
|
|
}
|
|
|
|
return onboardingStatus;
|
|
}
|
|
|
|
async function updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath: string, errorMessage: string | null): Promise<void> {
|
|
const state = readRemoteDevelopmentEnvironmentState();
|
|
const project = state.projectsByConfigPath[configFilePath];
|
|
if (project == null || state.anonymousRefreshToken == null) {
|
|
warnRemoteDevelopmentEnvironment("Skipping pushed config error update because local state is incomplete", {
|
|
configFilePath,
|
|
hasProject: project != null,
|
|
hasAnonymousRefreshToken: state.anonymousRefreshToken != null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const app = createInternalApp(project.apiBaseUrl, state.anonymousRefreshToken);
|
|
const user = await app.getUser({ or: "anonymous" });
|
|
const ownedProject = (await user.listOwnedProjects()).find((p) => p.id === project.projectId);
|
|
if (ownedProject == null) {
|
|
warnRemoteDevelopmentEnvironment("Skipping pushed config error update because the project is not owned by the anonymous user", {
|
|
projectId: project.projectId,
|
|
configFilePath,
|
|
});
|
|
return;
|
|
}
|
|
|
|
logRemoteDevelopmentEnvironment(errorMessage == null ? "Clearing pushed config error on development-environment project" : "Setting pushed config error on development-environment project", {
|
|
projectId: project.projectId,
|
|
configFilePath,
|
|
});
|
|
const response = await getStackAppRequestInternals(ownedProject.app).sendRequest(
|
|
"/internal/config/pushed-error",
|
|
errorMessage == null
|
|
? { method: "DELETE" }
|
|
: {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
error_message: errorMessage,
|
|
}),
|
|
},
|
|
"admin",
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to ${errorMessage == null ? "clear" : "set"} development-environment pushed config error (${response.status}): ${await response.text()}`);
|
|
}
|
|
}
|
|
|
|
type ConfigSyncResult = {
|
|
onboardingStatus: ProjectOnboardingStatus | undefined,
|
|
pushedConfig: boolean,
|
|
};
|
|
|
|
async function runConfigSyncSerialized<T>(configFilePath: string, operationName: string, operation: () => Promise<T>): Promise<T> {
|
|
const state = getGlobals();
|
|
while (true) {
|
|
const activeSync = state.activeConfigSyncs.get(configFilePath);
|
|
if (activeSync == null) {
|
|
break;
|
|
}
|
|
logRemoteDevelopmentEnvironment("Waiting for active config sync before starting another one", {
|
|
configFilePath,
|
|
operationName,
|
|
});
|
|
await activeSync;
|
|
}
|
|
|
|
let releaseActiveSync: () => void = () => {};
|
|
const activeSync = new Promise<void>((resolvePromise) => {
|
|
releaseActiveSync = resolvePromise;
|
|
});
|
|
state.activeConfigSyncs.set(configFilePath, activeSync);
|
|
try {
|
|
logRemoteDevelopmentEnvironment("Starting serialized config sync operation", {
|
|
configFilePath,
|
|
operationName,
|
|
});
|
|
return await operation();
|
|
} finally {
|
|
if (state.activeConfigSyncs.get(configFilePath) === activeSync) {
|
|
state.activeConfigSyncs.delete(configFilePath);
|
|
}
|
|
releaseActiveSync();
|
|
logRemoteDevelopmentEnvironment("Finished serialized config sync operation", {
|
|
configFilePath,
|
|
operationName,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function syncConfigToRemote(configFilePath: string): Promise<ConfigSyncResult> {
|
|
logRemoteDevelopmentEnvironment("Starting config sync", {
|
|
configFilePath,
|
|
});
|
|
const state = readRemoteDevelopmentEnvironmentState();
|
|
const project = state.projectsByConfigPath[configFilePath];
|
|
if (project == null || state.anonymousRefreshToken == null) {
|
|
warnRemoteDevelopmentEnvironment("Skipping config sync because local state is incomplete", {
|
|
configFilePath,
|
|
hasProject: project != null,
|
|
hasAnonymousRefreshToken: state.anonymousRefreshToken != null,
|
|
});
|
|
return { onboardingStatus: undefined, pushedConfig: false };
|
|
}
|
|
|
|
logRemoteDevelopmentEnvironment("Loading config file for sync", {
|
|
configFilePath,
|
|
});
|
|
const { config, showOnboarding } = await readConfigFile(configFilePath);
|
|
const configHash = sha256String(JSON.stringify({ config, showOnboarding, syncFormatVersion: CONFIG_SYNC_FORMAT_VERSION }));
|
|
logRemoteDevelopmentEnvironment("Loaded config file for sync", {
|
|
configFilePath,
|
|
configHash,
|
|
showOnboarding,
|
|
});
|
|
const app = createInternalApp(project.apiBaseUrl, state.anonymousRefreshToken);
|
|
const user = await app.getUser({ or: "anonymous" });
|
|
logRemoteDevelopmentEnvironment("Ensuring development-environment project ownership for config sync", {
|
|
projectId: project.projectId,
|
|
configFilePath,
|
|
});
|
|
const ownedProject = (await user.listOwnedProjects()).find((p) => p.id === project.projectId);
|
|
if (ownedProject == null) {
|
|
warnRemoteDevelopmentEnvironment("Skipping config sync because the project is not owned by the anonymous user", {
|
|
projectId: project.projectId,
|
|
configFilePath,
|
|
});
|
|
return { onboardingStatus: undefined, pushedConfig: false };
|
|
}
|
|
logRemoteDevelopmentEnvironment("Syncing onboarding status before config push", {
|
|
projectId: project.projectId,
|
|
configFilePath,
|
|
showOnboarding,
|
|
});
|
|
const onboardingStatus = await syncRemoteDevelopmentEnvironmentOnboardingStatus(ownedProject, showOnboarding);
|
|
if (project.lastSyncedConfigHash === configHash) {
|
|
logRemoteDevelopmentEnvironment("Skipping config push because remote config hash is current", {
|
|
projectId: project.projectId,
|
|
configFilePath,
|
|
configHash,
|
|
onboardingStatus,
|
|
});
|
|
await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, null);
|
|
return { onboardingStatus, pushedConfig: false };
|
|
}
|
|
|
|
logRemoteDevelopmentEnvironment("Sending config push request to development-environment project", {
|
|
projectId: project.projectId,
|
|
configFilePath,
|
|
configHash,
|
|
showOnboarding,
|
|
});
|
|
await ownedProject.replaceConfigOverride("branch", config);
|
|
|
|
updateRemoteDevelopmentEnvironmentState((current) => ({
|
|
...current,
|
|
projectsByConfigPath: {
|
|
...current.projectsByConfigPath,
|
|
[configFilePath]: {
|
|
...project,
|
|
lastSyncedConfigHash: configHash,
|
|
updatedAtMillis: Date.now(),
|
|
},
|
|
},
|
|
}));
|
|
logRemoteDevelopmentEnvironment("Synced config to development-environment project", {
|
|
projectId: project.projectId,
|
|
configFilePath,
|
|
configHash,
|
|
showOnboarding,
|
|
onboardingStatus,
|
|
});
|
|
return { onboardingStatus, pushedConfig: true };
|
|
}
|
|
|
|
function scheduleSync(configFilePath: string): void {
|
|
const state = getGlobals();
|
|
if (state.synchronouslyUpdatingConfigFiles.has(configFilePath)) {
|
|
logRemoteDevelopmentEnvironment("Skipping async config sync during synchronous dashboard update", {
|
|
configFilePath,
|
|
});
|
|
return;
|
|
}
|
|
const existing = state.syncTimers.get(configFilePath);
|
|
if (existing != null) {
|
|
clearTimeout(existing);
|
|
}
|
|
logRemoteDevelopmentEnvironment(existing == null ? "Scheduling config sync after local file change" : "Rescheduling config sync after another local file change", {
|
|
configFilePath,
|
|
debounceMs: SYNC_DEBOUNCE_MS,
|
|
hasActiveSync: state.activeConfigSyncs.has(configFilePath),
|
|
});
|
|
const timer = setTimeout(() => {
|
|
state.syncTimers.delete(configFilePath);
|
|
runAsynchronously(
|
|
async () => {
|
|
try {
|
|
const result = await runConfigSyncSerialized(
|
|
configFilePath,
|
|
"debounced local file change",
|
|
() => syncConfigToRemote(configFilePath),
|
|
);
|
|
state.syncErrors.delete(configFilePath);
|
|
if (result.pushedConfig) {
|
|
recordConfigSyncEvent(configFilePath, { status: "success" });
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = errorToNiceString(error);
|
|
const cliErrorMessage = formatConfigSyncErrorForCli(configFilePath, error);
|
|
warnRemoteDevelopmentEnvironment("Config sync failed", {
|
|
configFilePath,
|
|
error: errorMessage,
|
|
});
|
|
state.syncErrors.set(configFilePath, error instanceof Error ? error : new Error(errorMessage));
|
|
await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, cliErrorMessage)
|
|
.catch((pushedConfigErrorUpdateError: unknown) => {
|
|
warnRemoteDevelopmentEnvironment("Failed to update pushed config error after config sync failure", {
|
|
configFilePath,
|
|
error: errorToNiceString(pushedConfigErrorUpdateError),
|
|
});
|
|
});
|
|
recordConfigSyncEvent(configFilePath, {
|
|
status: "error",
|
|
errorMessage: cliErrorMessage,
|
|
});
|
|
}
|
|
},
|
|
{
|
|
noErrorLogging: true,
|
|
},
|
|
);
|
|
}, SYNC_DEBOUNCE_MS);
|
|
timer.unref();
|
|
state.syncTimers.set(configFilePath, timer);
|
|
}
|
|
|
|
async function syncConfigToRemoteNow(configFilePath: string): Promise<ConfigSyncResult> {
|
|
const state = getGlobals();
|
|
const pendingTimer = state.syncTimers.get(configFilePath);
|
|
if (pendingTimer != null) {
|
|
clearTimeout(pendingTimer);
|
|
state.syncTimers.delete(configFilePath);
|
|
logRemoteDevelopmentEnvironment("Canceled pending debounced config sync before immediate sync", {
|
|
configFilePath,
|
|
});
|
|
}
|
|
const result = await runConfigSyncSerialized(
|
|
configFilePath,
|
|
"immediate sync",
|
|
() => syncConfigToRemote(configFilePath),
|
|
);
|
|
state.syncErrors.delete(configFilePath);
|
|
return result;
|
|
}
|
|
|
|
function ensureWatcher(configFilePath: string): void {
|
|
const state = getGlobals();
|
|
if (state.watchers.has(configFilePath)) return;
|
|
const watcher = watch(configFilePath, { persistent: false }, () => {
|
|
scheduleSync(configFilePath);
|
|
});
|
|
state.watchers.set(configFilePath, watcher);
|
|
logRemoteDevelopmentEnvironment("Started watching config file", {
|
|
configFilePath,
|
|
watchedConfigFiles: state.watchers.size,
|
|
});
|
|
}
|
|
|
|
function ensureShutdownTimer(): void {
|
|
const state = getGlobals();
|
|
if (state.shutdownTimerStarted) return;
|
|
state.shutdownTimerStarted = true;
|
|
logRemoteDevelopmentEnvironment("Started shutdown timer", {
|
|
sessionTtlMs: SESSION_TTL_MS,
|
|
startupEmptySessionGraceMs: STARTUP_EMPTY_SESSION_GRACE_MS,
|
|
});
|
|
const timer = setInterval(() => {
|
|
const now = performance.now();
|
|
for (const [id, session] of state.sessions.entries()) {
|
|
const ttlMs = session.receivedFirstHeartbeat ? SESSION_TTL_MS : FIRST_HEARTBEAT_TTL_MS;
|
|
if (now - session.lastHeartbeatMs > ttlMs) {
|
|
warnRemoteDevelopmentEnvironment("Expiring stale session", {
|
|
sessionId: id,
|
|
ageMs: Math.round(now - session.lastHeartbeatMs),
|
|
activeSessionsBeforeExpire: state.sessions.size,
|
|
receivedFirstHeartbeat: session.receivedFirstHeartbeat,
|
|
});
|
|
state.sessions.delete(id);
|
|
}
|
|
}
|
|
if (state.sessions.size === 0 && state.activeOperations === 0 && (state.hasClosedSession || now - state.startedAtMs > STARTUP_EMPTY_SESSION_GRACE_MS)) {
|
|
logRemoteDevelopmentEnvironment("No active sessions remain; shutting down local dashboard", {
|
|
uptimeMs: Math.round(now - state.startedAtMs),
|
|
watchedConfigFiles: state.watchers.size,
|
|
pendingSyncs: state.syncTimers.size,
|
|
activeConfigSyncs: state.activeConfigSyncs.size,
|
|
syncErrors: state.syncErrors.size,
|
|
activeOperations: state.activeOperations,
|
|
hasClosedSession: state.hasClosedSession,
|
|
});
|
|
for (const watcher of state.watchers.values()) watcher.close();
|
|
process.exit(0);
|
|
}
|
|
}, 5_000);
|
|
timer.unref();
|
|
}
|
|
|
|
export function startRemoteDevelopmentEnvironmentLifecycle(): void {
|
|
assertRemoteDevelopmentEnvironmentEnabled();
|
|
if (getGlobals().shutdownTimerStarted) return;
|
|
logRemoteDevelopmentEnvironment("Starting local dashboard lifecycle");
|
|
ensureShutdownTimer();
|
|
}
|
|
|
|
export async function registerRemoteDevelopmentEnvironmentSession(options: {
|
|
apiBaseUrl: string,
|
|
configPath: string,
|
|
}): Promise<{ sessionId: string, env: Record<string, string>, projectId: string, onboardingOutstanding: boolean }> {
|
|
assertRemoteDevelopmentEnvironmentEnabled();
|
|
startRemoteDevelopmentEnvironmentLifecycle();
|
|
const configFilePath = resolveConfigFilePath(options.configPath);
|
|
const endOperation = beginRemoteDevelopmentEnvironmentOperation("session registration", {
|
|
apiBaseUrl: options.apiBaseUrl,
|
|
configFilePath,
|
|
});
|
|
try {
|
|
logRemoteDevelopmentEnvironment("Registering CLI session", {
|
|
apiBaseUrl: options.apiBaseUrl,
|
|
configFilePath,
|
|
});
|
|
ensureConfigFileExists(configFilePath);
|
|
const state = readRemoteDevelopmentEnvironmentState();
|
|
const { project } = await getOrCreateProject({
|
|
apiBaseUrl: options.apiBaseUrl,
|
|
configFilePath,
|
|
anonymousRefreshToken: state.anonymousRefreshToken,
|
|
}).catch((error: unknown) => throwApiUnavailableIfConnectionFailure(options.apiBaseUrl, error));
|
|
ensureWatcher(configFilePath);
|
|
const sessionId = randomUUID();
|
|
const globals = getGlobals();
|
|
globals.sessions.set(sessionId, {
|
|
configFilePath,
|
|
lastHeartbeatMs: performance.now(),
|
|
receivedFirstHeartbeat: false,
|
|
lastDeliveredConfigSyncEventId: globals.nextConfigSyncEventId - 1,
|
|
});
|
|
let syncResult: ConfigSyncResult = { onboardingStatus: undefined, pushedConfig: false };
|
|
try {
|
|
syncResult = await syncConfigToRemoteNow(configFilePath);
|
|
} catch (error) {
|
|
if (errorLooksLikeApiConnectionFailure(error)) {
|
|
globals.sessions.delete(sessionId);
|
|
throw new RemoteDevelopmentEnvironmentApiUnavailableError(options.apiBaseUrl, error);
|
|
}
|
|
const errorMessage = errorToNiceString(error);
|
|
warnRemoteDevelopmentEnvironment("Initial config sync failed", {
|
|
sessionId,
|
|
projectId: project.projectId,
|
|
configFilePath,
|
|
error: errorMessage,
|
|
});
|
|
globals.syncErrors.set(configFilePath, error instanceof Error ? error : new Error(errorMessage));
|
|
const cliErrorMessage = formatConfigSyncErrorForCli(configFilePath, error);
|
|
await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, cliErrorMessage)
|
|
.catch((pushedConfigErrorUpdateError: unknown) => {
|
|
warnRemoteDevelopmentEnvironment("Failed to update pushed config error after initial config sync failure", {
|
|
configFilePath,
|
|
error: errorToNiceString(pushedConfigErrorUpdateError),
|
|
});
|
|
});
|
|
recordConfigSyncEvent(configFilePath, {
|
|
status: "error",
|
|
errorMessage: cliErrorMessage,
|
|
});
|
|
}
|
|
logRemoteDevelopmentEnvironment("Registered CLI session", {
|
|
sessionId,
|
|
projectId: project.projectId,
|
|
activeSessions: getGlobals().sessions.size,
|
|
configFilePath,
|
|
});
|
|
return {
|
|
sessionId,
|
|
env: envVarsForProject(project),
|
|
projectId: project.projectId,
|
|
onboardingOutstanding: syncResult.onboardingStatus != null && syncResult.onboardingStatus !== "completed",
|
|
};
|
|
} finally {
|
|
endOperation();
|
|
}
|
|
}
|
|
|
|
export function heartbeatRemoteDevelopmentEnvironmentSession(sessionId: string): {
|
|
configSyncEvents: RemoteDevelopmentEnvironmentConfigSyncEvent[],
|
|
} | null {
|
|
assertRemoteDevelopmentEnvironmentEnabled();
|
|
const session = getGlobals().sessions.get(sessionId);
|
|
if (session == null) {
|
|
warnRemoteDevelopmentEnvironment("Received heartbeat for unknown session", {
|
|
sessionId,
|
|
});
|
|
return null;
|
|
}
|
|
session.lastHeartbeatMs = performance.now();
|
|
session.receivedFirstHeartbeat = true;
|
|
return {
|
|
configSyncEvents: drainConfigSyncEventsForSession(session),
|
|
};
|
|
}
|
|
|
|
export function getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(): { code: string, expiresAtMillis: number } | null {
|
|
assertRemoteDevelopmentEnvironmentEnabled();
|
|
return peekRemoteDevelopmentEnvironmentBrowserSecretConfirmationCodeForCli();
|
|
}
|
|
|
|
export function closeRemoteDevelopmentEnvironmentSession(sessionId: string): void {
|
|
assertRemoteDevelopmentEnvironmentEnabled();
|
|
const state = getGlobals();
|
|
const existed = state.sessions.delete(sessionId);
|
|
if (existed) {
|
|
state.hasClosedSession = true;
|
|
}
|
|
logRemoteDevelopmentEnvironment("Closed CLI session", {
|
|
sessionId,
|
|
existed,
|
|
activeSessions: state.sessions.size,
|
|
});
|
|
}
|
|
|
|
export function getRemoteDevelopmentEnvironmentHealth(): {
|
|
healthy: boolean,
|
|
configFilePath?: string,
|
|
} {
|
|
assertRemoteDevelopmentEnvironmentEnabled();
|
|
const globals = getGlobals();
|
|
const activeSession = globals.sessions.values().next().value as ActiveSession | undefined;
|
|
if (activeSession != null) {
|
|
return {
|
|
healthy: true,
|
|
configFilePath: activeSession.configFilePath,
|
|
};
|
|
}
|
|
|
|
const state = readRemoteDevelopmentEnvironmentState();
|
|
let configFilePath: string | undefined;
|
|
let latestUpdatedAtMillis = -Infinity;
|
|
for (const [projectConfigFilePath, project] of Object.entries(state.projectsByConfigPath)) {
|
|
if (project == null || project.updatedAtMillis <= latestUpdatedAtMillis) continue;
|
|
configFilePath = projectConfigFilePath;
|
|
latestUpdatedAtMillis = project.updatedAtMillis;
|
|
}
|
|
|
|
return {
|
|
healthy: false,
|
|
configFilePath,
|
|
};
|
|
}
|
|
|
|
export function getRemoteDevelopmentEnvironmentDebugSnapshot(): RemoteDevelopmentEnvironmentDebugSnapshot {
|
|
assertRemoteDevelopmentEnvironmentEnabled();
|
|
const globals = getGlobals();
|
|
const now = performance.now();
|
|
const unixNow = Date.now();
|
|
const state = readRemoteDevelopmentEnvironmentState();
|
|
return {
|
|
uptimeMs: Math.round(now - globals.startedAtMs),
|
|
shutdownTimerStarted: globals.shutdownTimerStarted,
|
|
activeOperations: globals.activeOperations,
|
|
hasClosedSession: globals.hasClosedSession,
|
|
sessions: [...globals.sessions.entries()].map(([sessionId, session]) => {
|
|
const ttlMs = session.receivedFirstHeartbeat ? SESSION_TTL_MS : FIRST_HEARTBEAT_TTL_MS;
|
|
const lastHeartbeatAgeMs = Math.round(now - session.lastHeartbeatMs);
|
|
return {
|
|
sessionId,
|
|
configFilePath: session.configFilePath,
|
|
lastHeartbeatAgeMs,
|
|
ttlMs,
|
|
expiresInMs: Math.max(0, ttlMs - lastHeartbeatAgeMs),
|
|
receivedFirstHeartbeat: session.receivedFirstHeartbeat,
|
|
};
|
|
}),
|
|
watchedConfigFiles: [...globals.watchers.keys()],
|
|
pendingSyncConfigFiles: [...globals.syncTimers.keys()],
|
|
syncErrors: [...globals.syncErrors.entries()].map(([configFilePath, error]) => ({
|
|
configFilePath,
|
|
error: errorToNiceString(error),
|
|
})),
|
|
pendingConfigSyncEvents: globals.configSyncEvents.map((event) => event.status === "success"
|
|
? {
|
|
id: event.id,
|
|
configFilePath: event.configFilePath,
|
|
status: "success",
|
|
createdAgoMs: Math.max(0, unixNow - event.createdAtMillis),
|
|
}
|
|
: {
|
|
id: event.id,
|
|
configFilePath: event.configFilePath,
|
|
status: "error",
|
|
errorMessage: event.errorMessage,
|
|
createdAgoMs: Math.max(0, unixNow - event.createdAtMillis),
|
|
}),
|
|
synchronouslyUpdatingConfigFiles: [...globals.synchronouslyUpdatingConfigFiles],
|
|
localDashboards: Object.values(state.localDashboardsByPort ?? {})
|
|
.filter((dashboard) => dashboard != null)
|
|
.map((dashboard) => ({
|
|
port: dashboard.port,
|
|
pid: dashboard.pid,
|
|
startedAgoMs: Math.max(0, unixNow - dashboard.startedAtMillis),
|
|
logPath: dashboard.logPath,
|
|
})),
|
|
pendingBrowserSecretConfirmationCodes: Object.entries(state.pendingBrowserSecretConfirmationCodesByPort ?? {})
|
|
.flatMap(([port, code]) => code == null ? [] : [{
|
|
port,
|
|
code: code.code,
|
|
expiresInMs: Math.max(0, code.expiresAtMillis - unixNow),
|
|
updatedAgoMs: Math.max(0, unixNow - code.updatedAtMillis),
|
|
}]),
|
|
projects: Object.entries(state.projectsByConfigPath)
|
|
.flatMap(([configFilePath, project]) => project == null ? [] : [{
|
|
configFilePath,
|
|
projectId: project.projectId,
|
|
teamId: project.teamId,
|
|
apiBaseUrl: project.apiBaseUrl,
|
|
updatedAgoMs: Math.max(0, unixNow - project.updatedAtMillis),
|
|
hasLastSyncedConfigHash: project.lastSyncedConfigHash != null,
|
|
}]),
|
|
};
|
|
}
|
|
|
|
export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: {
|
|
sessionId?: string,
|
|
projectId?: string,
|
|
configUpdate: Config,
|
|
waitForSync?: boolean,
|
|
}): Promise<void> {
|
|
assertRemoteDevelopmentEnvironmentEnabled();
|
|
const configUpdateOperationId = randomUUID();
|
|
const waitForSync = options.waitForSync ?? true;
|
|
const configUpdateLogDetails = {
|
|
configUpdateOperationId,
|
|
sessionId: options.sessionId,
|
|
projectId: options.projectId,
|
|
waitForSync,
|
|
...summarizeConfigUpdateForLog(options.configUpdate),
|
|
};
|
|
const endOperation = beginRemoteDevelopmentEnvironmentOperation("config update", {
|
|
...configUpdateLogDetails,
|
|
});
|
|
let resolvedConfigFilePath: string | undefined;
|
|
try {
|
|
const state = getGlobals();
|
|
const sessionEntry = (() => {
|
|
if (options.sessionId != null) {
|
|
const session = state.sessions.get(options.sessionId);
|
|
return session == null ? undefined : { sessionId: options.sessionId, session };
|
|
}
|
|
if (options.projectId == null) {
|
|
throw new Error("Remote development environment config update requires a session ID or project ID.");
|
|
}
|
|
for (const [sessionId, activeSession] of state.sessions.entries()) {
|
|
const stateProject = readRemoteDevelopmentEnvironmentState().projectsByConfigPath[activeSession.configFilePath];
|
|
if (stateProject?.projectId === options.projectId) {
|
|
return { sessionId, session: activeSession };
|
|
}
|
|
}
|
|
return undefined;
|
|
})();
|
|
if (sessionEntry == null) {
|
|
warnRemoteDevelopmentEnvironment("Could not resolve active session for config update", {
|
|
...configUpdateLogDetails,
|
|
activeSessions: state.sessions.size,
|
|
});
|
|
throw new Error("Remote development environment session is not active.");
|
|
}
|
|
const session = sessionEntry.session;
|
|
const configFilePath = session.configFilePath;
|
|
resolvedConfigFilePath = configFilePath;
|
|
logRemoteDevelopmentEnvironment("Resolved active session for config update", {
|
|
...configUpdateLogDetails,
|
|
resolvedSessionId: sessionEntry.sessionId,
|
|
configFilePath,
|
|
activeSessions: state.sessions.size,
|
|
});
|
|
logRemoteDevelopmentEnvironment("Applying config update from local dashboard", {
|
|
...configUpdateLogDetails,
|
|
resolvedSessionId: sessionEntry.sessionId,
|
|
configFilePath,
|
|
});
|
|
const localWriteStartedAtMs = performance.now();
|
|
if (!waitForSync) {
|
|
logRemoteDevelopmentEnvironment("Writing config update without waiting for remote sync", {
|
|
...configUpdateLogDetails,
|
|
resolvedSessionId: sessionEntry.sessionId,
|
|
configFilePath,
|
|
});
|
|
const currentConfig = (await readConfigFile(configFilePath)).config;
|
|
await replaceConfigObject(configFilePath, override(currentConfig, options.configUpdate));
|
|
scheduleSync(configFilePath);
|
|
logRemoteDevelopmentEnvironment("Wrote config update and scheduled remote sync", {
|
|
...configUpdateLogDetails,
|
|
resolvedSessionId: sessionEntry.sessionId,
|
|
configFilePath,
|
|
localWriteElapsedMs: Math.round(performance.now() - localWriteStartedAtMs),
|
|
});
|
|
} else {
|
|
state.synchronouslyUpdatingConfigFiles.add(configFilePath);
|
|
try {
|
|
logRemoteDevelopmentEnvironment("Writing config update before immediate remote sync", {
|
|
...configUpdateLogDetails,
|
|
resolvedSessionId: sessionEntry.sessionId,
|
|
configFilePath,
|
|
});
|
|
await updateConfigObject(configFilePath, options.configUpdate);
|
|
logRemoteDevelopmentEnvironment("Wrote config update before immediate remote sync", {
|
|
...configUpdateLogDetails,
|
|
resolvedSessionId: sessionEntry.sessionId,
|
|
configFilePath,
|
|
localWriteElapsedMs: Math.round(performance.now() - localWriteStartedAtMs),
|
|
});
|
|
} finally {
|
|
setTimeout(() => {
|
|
state.synchronouslyUpdatingConfigFiles.delete(configFilePath);
|
|
}, SYNC_DEBOUNCE_MS).unref();
|
|
}
|
|
try {
|
|
const syncStartedAtMs = performance.now();
|
|
const result = await syncConfigToRemoteNow(configFilePath);
|
|
if (result.pushedConfig) {
|
|
recordConfigSyncEvent(configFilePath, { status: "success" });
|
|
}
|
|
logRemoteDevelopmentEnvironment("Immediate remote sync after config update completed", {
|
|
...configUpdateLogDetails,
|
|
resolvedSessionId: sessionEntry.sessionId,
|
|
configFilePath,
|
|
syncElapsedMs: Math.round(performance.now() - syncStartedAtMs),
|
|
pushedConfig: result.pushedConfig,
|
|
onboardingStatus: result.onboardingStatus,
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = errorToNiceString(error);
|
|
warnRemoteDevelopmentEnvironment("Immediate remote sync after config update failed", {
|
|
...configUpdateLogDetails,
|
|
resolvedSessionId: sessionEntry.sessionId,
|
|
configFilePath,
|
|
...formatErrorForRemoteDevelopmentEnvironmentLog(error),
|
|
});
|
|
state.syncErrors.set(configFilePath, error instanceof Error ? error : new Error(errorMessage));
|
|
const cliErrorMessage = formatConfigSyncErrorForCli(configFilePath, error);
|
|
await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, cliErrorMessage)
|
|
.catch((pushedConfigErrorUpdateError: unknown) => {
|
|
warnRemoteDevelopmentEnvironment("Failed to update pushed config error after dashboard config sync failure", {
|
|
...configUpdateLogDetails,
|
|
resolvedSessionId: sessionEntry.sessionId,
|
|
configFilePath,
|
|
...formatErrorForRemoteDevelopmentEnvironmentLog(pushedConfigErrorUpdateError),
|
|
});
|
|
});
|
|
recordConfigSyncEvent(configFilePath, {
|
|
status: "error",
|
|
errorMessage: cliErrorMessage,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
logRemoteDevelopmentEnvironment("Applied config update from local dashboard", {
|
|
...configUpdateLogDetails,
|
|
resolvedSessionId: sessionEntry.sessionId,
|
|
configFilePath,
|
|
});
|
|
} catch (error) {
|
|
warnRemoteDevelopmentEnvironment("Failed to apply config update from local dashboard", {
|
|
...configUpdateLogDetails,
|
|
...(resolvedConfigFilePath == null ? {} : { configFilePath: resolvedConfigFilePath }),
|
|
...formatErrorForRemoteDevelopmentEnvironmentLog(error),
|
|
});
|
|
throw error;
|
|
} finally {
|
|
endOperation();
|
|
}
|
|
}
|