mirror of
https://github.com/steel-dev/cli.git
synced 2026-09-14 20:47:34 +08:00
225 lines
5.5 KiB
TypeScript
225 lines
5.5 KiB
TypeScript
import * as fs from 'node:fs/promises';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import {BrowserAdapterError} from '../errors.js';
|
|
import type {BrowserSessionMode, BrowserSessionState} from './types.js';
|
|
|
|
const CONFIG_DIR =
|
|
process.env.STEEL_CONFIG_DIR?.trim() ||
|
|
path.join(os.homedir(), '.config', 'steel');
|
|
const SESSION_STATE_PATH = path.join(CONFIG_DIR, 'browser-session-state.json');
|
|
const SESSION_STATE_LOCK_PATH = `${SESSION_STATE_PATH}.lock`;
|
|
const LOCK_TIMEOUT_MS = 5000;
|
|
const LOCK_RETRY_MS = 50;
|
|
const LOCK_STALE_MS = 15_000;
|
|
|
|
function wait(milliseconds: number): Promise<void> {
|
|
return new Promise(resolve => {
|
|
setTimeout(resolve, milliseconds);
|
|
});
|
|
}
|
|
|
|
function getDefaultSessionState(): BrowserSessionState {
|
|
return {
|
|
activeSessionId: null,
|
|
activeSessionMode: null,
|
|
activeSessionName: null,
|
|
namedSessions: {
|
|
cloud: {},
|
|
local: {},
|
|
},
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
|
|
function coerceSessionState(input: unknown): BrowserSessionState {
|
|
const defaultState = getDefaultSessionState();
|
|
if (!input || typeof input !== 'object') {
|
|
return defaultState;
|
|
}
|
|
|
|
const state = input as Partial<BrowserSessionState>;
|
|
|
|
const namedSessions =
|
|
state.namedSessions && typeof state.namedSessions === 'object'
|
|
? (state.namedSessions as Record<string, unknown>)
|
|
: {};
|
|
const cloudSessions =
|
|
namedSessions && typeof namedSessions.cloud === 'object'
|
|
? (namedSessions.cloud as Record<string, string>)
|
|
: {};
|
|
const localSessions =
|
|
namedSessions && typeof namedSessions.local === 'object'
|
|
? (namedSessions.local as Record<string, string>)
|
|
: {};
|
|
|
|
return {
|
|
activeSessionId:
|
|
typeof state.activeSessionId === 'string' ? state.activeSessionId : null,
|
|
activeSessionMode:
|
|
state.activeSessionMode === 'cloud' || state.activeSessionMode === 'local'
|
|
? state.activeSessionMode
|
|
: null,
|
|
activeSessionName:
|
|
typeof state.activeSessionName === 'string'
|
|
? state.activeSessionName
|
|
: null,
|
|
namedSessions: {
|
|
cloud: {...cloudSessions},
|
|
local: {...localSessions},
|
|
},
|
|
updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : null,
|
|
};
|
|
}
|
|
|
|
export async function readSessionState(): Promise<BrowserSessionState> {
|
|
try {
|
|
const rawState = await fs.readFile(SESSION_STATE_PATH, 'utf-8');
|
|
const parsedState = JSON.parse(rawState) as unknown;
|
|
return coerceSessionState(parsedState);
|
|
} catch {
|
|
return getDefaultSessionState();
|
|
}
|
|
}
|
|
|
|
async function writeSessionState(state: BrowserSessionState): Promise<void> {
|
|
state.updatedAt = new Date().toISOString();
|
|
await fs.mkdir(CONFIG_DIR, {recursive: true});
|
|
await fs.writeFile(SESSION_STATE_PATH, JSON.stringify(state, null, 2));
|
|
}
|
|
|
|
async function releaseLock(): Promise<void> {
|
|
try {
|
|
await fs.unlink(SESSION_STATE_LOCK_PATH);
|
|
} catch {
|
|
// Ignore lock cleanup errors.
|
|
}
|
|
}
|
|
|
|
async function acquireLock(): Promise<void> {
|
|
const startedAt = Date.now();
|
|
|
|
while (true) {
|
|
try {
|
|
const lockHandle = await fs.open(SESSION_STATE_LOCK_PATH, 'wx');
|
|
await lockHandle.close();
|
|
return;
|
|
} catch (error) {
|
|
const errorCode = (error as NodeJS.ErrnoException).code;
|
|
if (errorCode !== 'EEXIST') {
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
const lockStat = await fs.stat(SESSION_STATE_LOCK_PATH);
|
|
const lockAge = Date.now() - lockStat.mtimeMs;
|
|
if (lockAge > LOCK_STALE_MS) {
|
|
await releaseLock();
|
|
continue;
|
|
}
|
|
} catch {
|
|
// Another process may have released the lock.
|
|
}
|
|
|
|
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
|
|
throw new BrowserAdapterError(
|
|
'API_ERROR',
|
|
'Timed out waiting for browser session state lock.',
|
|
);
|
|
}
|
|
|
|
await wait(LOCK_RETRY_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function withSessionStateLock<T>(
|
|
operation: (state: BrowserSessionState) => Promise<T>,
|
|
options?: {write?: boolean},
|
|
): Promise<T> {
|
|
await fs.mkdir(CONFIG_DIR, {recursive: true});
|
|
await acquireLock();
|
|
|
|
try {
|
|
const state = await readSessionState();
|
|
const result = await operation(state);
|
|
if (options?.write !== false) {
|
|
await writeSessionState(state);
|
|
}
|
|
return result;
|
|
} finally {
|
|
await releaseLock();
|
|
}
|
|
}
|
|
|
|
export function setActiveSessionState(
|
|
state: BrowserSessionState,
|
|
mode: BrowserSessionMode,
|
|
sessionId: string,
|
|
sessionName: string | null,
|
|
): void {
|
|
state.activeSessionMode = mode;
|
|
state.activeSessionId = sessionId;
|
|
state.activeSessionName = sessionName;
|
|
}
|
|
|
|
export function clearActiveSessionState(
|
|
state: BrowserSessionState,
|
|
mode: BrowserSessionMode,
|
|
sessionId: string,
|
|
): void {
|
|
if (state.activeSessionMode === mode && state.activeSessionId === sessionId) {
|
|
state.activeSessionMode = null;
|
|
state.activeSessionId = null;
|
|
state.activeSessionName = null;
|
|
}
|
|
|
|
for (const [name, mappedSessionId] of Object.entries(
|
|
state.namedSessions[mode],
|
|
)) {
|
|
if (mappedSessionId === sessionId) {
|
|
delete state.namedSessions[mode][name];
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resolveCandidateSessionId(
|
|
state: BrowserSessionState,
|
|
mode: BrowserSessionMode,
|
|
sessionName: string | null,
|
|
): string | null {
|
|
if (sessionName) {
|
|
return state.namedSessions[mode][sessionName] || null;
|
|
}
|
|
|
|
if (state.activeSessionMode === mode && state.activeSessionId) {
|
|
return state.activeSessionId;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function resolveNameFromState(
|
|
state: BrowserSessionState,
|
|
mode: BrowserSessionMode,
|
|
sessionId: string,
|
|
): string | null {
|
|
for (const [name, mappedSessionId] of Object.entries(
|
|
state.namedSessions[mode],
|
|
)) {
|
|
if (mappedSessionId === sessionId) {
|
|
return name;
|
|
}
|
|
}
|
|
|
|
if (
|
|
state.activeSessionMode === mode &&
|
|
state.activeSessionId === sessionId &&
|
|
state.activeSessionName
|
|
) {
|
|
return state.activeSessionName;
|
|
}
|
|
|
|
return null;
|
|
}
|