import { randomBytes, timingSafeEqual, createHash } from 'node:crypto'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { isIP } from 'node:net'; import { resolve } from 'node:path'; import { appendProjectEvent } from '../events.js'; import { writeStageCheckpoint } from '../checkpoints.js'; import { VclawError } from '../errors.js'; import { safeErrorBody } from '../http-error-safety.js'; import { isProjectSlug } from '../projects.js'; import { assertStageReady } from '../stage-guards.js'; import { buildStudioPlan } from '../studio/planner.js'; import { loadStudioProjectContext } from '../studio/project-context.js'; import { getStockProvider } from '../stock-platform/registry.js'; import { importStockRendition } from '../stock-platform/store.js'; import type { StockFetch, StockSearchResult } from '../stock-platform/types.js'; import { ensureProjectWorkspace, readProjectManifest, updateProjectManifestState, } from '../workspace.js'; import { buildCreatorUiProjectSnapshot } from './read-model.js'; import type { StudioAspectRatio, StudioAudioMode, StudioCreatorPlatform, StudioPlan, StudioSourceMode, } from '../studio/types.js'; import { buildCreatorCapabilities } from './capabilities.js'; import type { CreatorCapabilities } from './capabilities.js'; import type { StudioStepRunner } from '../studio/execute.js'; import { CreatorUiExecutionError, defaultCreatorUiStudioRunner, parseCreatorUiExecuteRequest, runCreatorUiExecution, } from './execution.js'; const SESSION_COOKIE = 'vclaw_creator_session'; const MAX_JSON_BYTES = 1024 * 1024; const PLATFORMS = ['generic', 'youtube-shorts', 'tiktok', 'instagram-reels'] as const; const ASPECT_RATIOS = ['16:9', '9:16', '1:1'] as const; const SOURCE_MODES = ['original-ai', 'stock-assisted', 'local-media'] as const; const AUDIO_MODES = ['none', 'narration', 'music', 'narration-and-music'] as const; const CREATOR_GOALS = ['create-video', 'creator-demo'] as const; const MAX_REMEMBERED_PLANS = 64; export interface CreatorUiLaunch { url: string; host: string; port: number; root: string; authRequired: true; remoteAccess: false; dryRun: boolean; close?: () => Promise; } export interface CreatorUiOptions { root: string; host?: string; port?: number; dryRun?: boolean; stockFetch?: StockFetch; stockApiKey?: string; capabilityEnv?: NodeJS.ProcessEnv; capabilityNow?: Date; capabilityProbeExecutable?: (name: 'python3' | 'bun' | 'ffmpeg') => string | undefined; studioRunStep?: StudioStepRunner; studioBaseEnv?: NodeJS.ProcessEnv; } export interface CreatorUiPlanRequest { goal?: 'create-video' | 'creator-demo'; project: string; intent: string; platform?: StudioCreatorPlatform; aspectRatio?: StudioAspectRatio; durationSeconds?: number; sourceMode?: StudioSourceMode; audioMode?: StudioAudioMode; } export interface CreatorUiPlanResponse { planDigest: string; plan: StudioPlan; } class CreatorUiHttpError extends Error { constructor( public readonly statusCode: number, public readonly code: string, message: string, ) { super(message); this.name = 'CreatorUiHttpError'; } } function formatHost(host: string): string { return isIP(host) === 6 ? `[${host}]` : host; } function normalizedHost(host: string): string { return host.trim().toLowerCase().replace(/^\[|\]$/g, ''); } function isLoopbackHost(host: string): boolean { const normalized = normalizedHost(host); return normalized === 'localhost' || normalized === '::1' || normalized.startsWith('127.'); } function isWildcardHost(host: string): boolean { const normalized = normalizedHost(host); return normalized === '0.0.0.0' || normalized === '::'; } function timingSafeStringEqual(left: string, right: string): boolean { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); } function cookies(request: IncomingMessage): Record { const result: Record = {}; for (const pair of (request.headers.cookie ?? '').split(';')) { const separator = pair.indexOf('='); if (separator < 1) continue; try { result[pair.slice(0, separator).trim()] = decodeURIComponent(pair.slice(separator + 1).trim()); } catch { // Malformed cookies cannot authenticate. } } return result; } function setSecurityHeaders(response: ServerResponse): void { response.setHeader('X-Content-Type-Options', 'nosniff'); response.setHeader('Referrer-Policy', 'no-referrer'); response.setHeader('X-Frame-Options', 'DENY'); response.setHeader( 'Content-Security-Policy', "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; media-src 'none'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", ); } function expectedOrigin(host: string, port: number): string { return `http://${formatHost(host)}:${port}`.toLowerCase(); } function requestAuthorityIsTrusted(request: IncomingMessage, host: string, port: number): boolean { const expectedHost = `${formatHost(host)}:${port}`.toLowerCase(); if ((request.headers.host ?? '').toLowerCase() !== expectedHost) return false; if (request.headers['sec-fetch-site'] === 'cross-site') return false; const origin = request.headers.origin; return !origin || origin.toLowerCase() === expectedOrigin(host, port); } function mutationHasCsrfProof(request: IncomingMessage, host: string, port: number): boolean { const origin = request.headers.origin; return typeof origin === 'string' && origin.toLowerCase() === expectedOrigin(host, port); } function sendText(response: ServerResponse, status: number, body: string): void { response.statusCode = status; response.setHeader('Content-Type', 'text/plain; charset=utf-8'); response.setHeader('Cache-Control', 'no-store'); response.end(`${safeErrorBody(body)}\n`); } function sendJson(response: ServerResponse, status: number, body: unknown): void { response.statusCode = status; response.setHeader('Content-Type', 'application/json; charset=utf-8'); response.setHeader('Cache-Control', 'no-store'); response.end(`${JSON.stringify(body, null, 2)}\n`); } function sendApiError(response: ServerResponse, error: CreatorUiHttpError): void { sendJson(response, error.statusCode, { error: { code: error.code, message: safeErrorBody(error.message), }, }); } function sendVclawApiError(response: ServerResponse, error: VclawError): void { const status = error.code === 'stock_auth_failed' ? 400 : error.code === 'stock_rate_limited' ? 429 : error.code === 'stock_provider_unavailable' || error.code === 'stock_download_failed' ? 502 : 400; sendJson(response, status, { error: { code: error.code, message: safeErrorBody(error.message), ...(error.details ? { details: error.details } : {}), }, }); } function isJsonRequest(request: IncomingMessage): boolean { const header = request.headers['content-type']; const value = Array.isArray(header) ? header[0] : header; return typeof value === 'string' && /^application\/json(?:\s*;|$)/i.test(value); } async function readJsonBody(request: IncomingMessage): Promise { const chunks: Buffer[] = []; let bytes = 0; for await (const chunk of request) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); bytes += buffer.length; if (bytes > MAX_JSON_BYTES) { throw new CreatorUiHttpError(413, 'creator_ui_body_too_large', 'Creator UI request body exceeds 1 MiB.'); } chunks.push(buffer); } const raw = Buffer.concat(chunks).toString('utf-8'); try { return raw.trim() ? JSON.parse(raw) as unknown : {}; } catch { throw new CreatorUiHttpError(400, 'creator_ui_invalid_json', 'Creator UI request body must be valid JSON.'); } } function assertChoice(name: string, value: unknown, allowed: readonly T[]): T | undefined { if (value === undefined) return undefined; if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T; throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', `${name} must be one of ${allowed.join(', ')}.`); } function parsePlanRequest(body: unknown): CreatorUiPlanRequest { if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'Creator UI plan body must be a JSON object.'); } const input = body as Record; const allowedKeys = new Set(['goal', 'project', 'intent', 'platform', 'aspectRatio', 'durationSeconds', 'sourceMode', 'audioMode']); const extra = Object.keys(input).filter((key) => !allowedKeys.has(key)); if (extra.length > 0) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', `Unexpected Creator UI plan field(s): ${extra.join(', ')}.`); } if (typeof input.project !== 'string' || !isProjectSlug(input.project)) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'project must be a valid project slug.'); } if (typeof input.intent !== 'string' || !input.intent.trim()) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'intent must be a non-empty string.'); } let durationSeconds: number | undefined; if (input.durationSeconds !== undefined) { const value = input.durationSeconds; if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'durationSeconds must be a positive integer.'); } durationSeconds = value; } const goal = input.goal === undefined ? 'create-video' : assertChoice('goal', input.goal, CREATOR_GOALS); return { goal, project: input.project, intent: input.intent, ...(input.platform !== undefined ? { platform: assertChoice('platform', input.platform, PLATFORMS) } : {}), ...(input.aspectRatio !== undefined ? { aspectRatio: assertChoice('aspectRatio', input.aspectRatio, ASPECT_RATIOS) } : {}), ...(durationSeconds !== undefined ? { durationSeconds } : {}), ...(goal === 'creator-demo' ? { sourceMode: 'local-media' as const } : input.sourceMode !== undefined ? { sourceMode: assertChoice('sourceMode', input.sourceMode, SOURCE_MODES) } : {}), ...(goal === 'creator-demo' ? { audioMode: 'none' as const } : input.audioMode !== undefined ? { audioMode: assertChoice('audioMode', input.audioMode, AUDIO_MODES) } : {}), }; } function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; if (value && typeof value === 'object') { return `{${Object.keys(value as Record).sort().map((key) => `${JSON.stringify(key)}:${stableJson((value as Record)[key])}`).join(',')}}`; } return JSON.stringify(value); } async function planResponse( root: string, request: CreatorUiPlanRequest, env: Readonly>, ): Promise { const projectContext = await loadStudioProjectContext(root, request.project, 'storyboard'); const plan = buildStudioPlan({ ...request, goal: request.goal ?? 'create-video', executionIntent: 'plan', dryRun: true, root, env, projectContext, }); const digest = createHash('sha256').update(stableJson({ request, plan })).digest('hex'); return { planDigest: `sha256:${digest}`, plan, }; } function rememberPlanRequest( plans: Map, digest: string, request: CreatorUiPlanRequest, ): void { if (!plans.has(digest) && plans.size >= MAX_REMEMBERED_PLANS) { const oldest = plans.keys().next().value as string | undefined; if (oldest) plans.delete(oldest); } plans.set(digest, request); } function parseStockSearchRequest(body: unknown): { query: string; provider?: string; page?: number; perPage?: number; } { if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'Creator UI stock search body must be a JSON object.'); } const input = body as Record; const allowedKeys = new Set(['query', 'provider', 'page', 'perPage']); const extra = Object.keys(input).filter((key) => !allowedKeys.has(key)); if (extra.length > 0) throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', `Unexpected stock search field(s): ${extra.join(', ')}.`); if (typeof input.query !== 'string' || !input.query.trim()) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'query must be a non-empty string.'); } const out: { query: string; provider?: string; page?: number; perPage?: number } = { query: input.query }; if (input.provider !== undefined) { if (input.provider !== 'pexels') throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'provider must be pexels.'); out.provider = input.provider; } for (const [field, key] of [['page', 'page'], ['perPage', 'perPage']] as const) { if (input[field] === undefined) continue; if (typeof input[field] !== 'number' || !Number.isInteger(input[field]) || input[field] <= 0) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', `${field} must be a positive integer.`); } out[key] = input[field] as number; } return out; } function parseStockImportRequest(body: unknown): { project: string; selection: StockSearchResult; renditionId: string; sceneIndex?: number; } { if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'Creator UI stock import body must be a JSON object.'); } const input = body as Record; const allowedKeys = new Set(['project', 'selection', 'renditionId', 'sceneIndex']); const extra = Object.keys(input).filter((key) => !allowedKeys.has(key)); if (extra.length > 0) throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', `Unexpected stock import field(s): ${extra.join(', ')}.`); if (typeof input.project !== 'string' || !isProjectSlug(input.project)) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'project must be a valid project slug.'); } if (!input.selection || typeof input.selection !== 'object' || Array.isArray(input.selection)) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'selection must be a stock search result object.'); } if (typeof input.renditionId !== 'string' || !input.renditionId.trim()) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'renditionId must be a non-empty string.'); } let sceneIndex: number | undefined; if (input.sceneIndex !== undefined) { if (typeof input.sceneIndex !== 'number' || !Number.isInteger(input.sceneIndex) || input.sceneIndex < 0) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'sceneIndex must be a non-negative integer.'); } sceneIndex = input.sceneIndex; } return { project: input.project, selection: input.selection as StockSearchResult, renditionId: input.renditionId, ...(sceneIndex !== undefined ? { sceneIndex } : {}), }; } async function stockSearchResponse( body: unknown, options: Pick, ): Promise { const request = parseStockSearchRequest(body); const provider = getStockProvider(request.provider); const results = await provider.searchVideos({ query: request.query, page: request.page, perPage: request.perPage, ...(options.stockApiKey !== undefined ? { apiKey: options.stockApiKey } : {}), ...(options.stockFetch ? { fetch: options.stockFetch } : {}), }); return { provider: provider.id, query: request.query, count: results.length, results, licenseNotice: 'Pexels media is governed by the Pexels License: https://www.pexels.com/license/', }; } async function stockImportResponse( root: string, body: unknown, options: Pick, ): Promise { const request = parseStockImportRequest(body); const workspace = await ensureProjectWorkspace(request.project, root); const projectManifest = await readProjectManifest(workspace); if (projectManifest) await assertStageReady(workspace, projectManifest.productionMode, 'assets'); const result = await importStockRendition({ workspace, selection: request.selection, renditionId: request.renditionId, ...(request.sceneIndex !== undefined ? { sceneIndex: request.sceneIndex } : {}), fetch: options.stockFetch ?? (fetch as unknown as StockFetch), }); await writeStageCheckpoint(workspace, { stage: 'assets', status: 'completed', generatedAt: result.receipt.importedAt, artifacts: { 'asset-manifest': result.manifestPath }, summary: 'Stock asset imported.', issues: [], nextAction: 'Run a review and record the verdict.', }); await appendProjectEvent(workspace, { type: 'artifact.stock-import.written', recordedAt: result.receipt.importedAt, payload: { provider: result.receipt.provider, providerAssetId: result.receipt.providerAssetId, receiptPath: result.receiptPath, assetPath: result.receipt.localAssetPath, reused: result.reused, }, }); if (projectManifest) { await updateProjectManifestState(workspace, { updatedAt: result.receipt.importedAt, currentStage: 'review', lastCompletedStage: 'assets', lastCheckpointStatus: 'completed', }); } return { assetPath: result.assetPath, receiptPath: result.receiptPath, manifestPath: result.manifestPath, receipt: result.receipt, reused: result.reused, }; } function creatorHtml(): string { return ` vclaw Creator UI

Creator Plan

Preview a fresh plan before execution.

Project details
Technical plan details

Stock video

Search Pexels, review its source and license, then import one rendition into the project above.


Capabilities

Technical capability details
`; } async function handleRequest( request: IncomingMessage, response: ServerResponse, context: { root: string; host: string; port: number; authToken: string; stockFetch?: StockFetch; stockApiKey?: string; capabilityEnv?: NodeJS.ProcessEnv; capabilityNow?: Date; capabilityProbeExecutable?: (name: 'python3' | 'bun' | 'ffmpeg') => string | undefined; studioRunStep: StudioStepRunner; studioBaseEnv?: NodeJS.ProcessEnv; planEnv: Readonly>; planRequests: Map; }, ): Promise { setSecurityHeaders(response); const url = new URL(request.url ?? '/', 'http://localhost'); if (!requestAuthorityIsTrusted(request, context.host, context.port)) { return sendText(response, 403, 'Untrusted Creator UI origin or host'); } const bootstrap = url.searchParams.get('token'); if ((url.pathname === '/' || url.pathname === '/creator-ui') && bootstrap) { if (!timingSafeStringEqual(bootstrap, context.authToken)) return sendText(response, 401, 'Invalid Creator UI launch token'); response.statusCode = 303; response.setHeader('Set-Cookie', `${SESSION_COOKIE}=${encodeURIComponent(context.authToken)}; HttpOnly; SameSite=Strict; Path=/`); response.setHeader('Location', '/creator-ui'); response.setHeader('Cache-Control', 'no-store'); response.end(); return; } const session = cookies(request)[SESSION_COOKIE]; if (!session || !timingSafeStringEqual(session, context.authToken)) { return sendText(response, 401, 'Open the one-time Creator UI launch URL to start an authenticated session'); } if (url.pathname === '/' || url.pathname === '/creator-ui') { if (request.method !== 'GET') return sendText(response, 405, 'Method not allowed'); response.statusCode = 200; response.setHeader('Content-Type', 'text/html; charset=utf-8'); response.setHeader('Cache-Control', 'no-store'); response.end(creatorHtml()); return; } if (url.pathname === '/api/project') { try { if (request.method !== 'GET') throw new CreatorUiHttpError(405, 'creator_ui_method_not_allowed', 'Method not allowed.'); const project = url.searchParams.get('project'); if (!project || !isProjectSlug(project)) { throw new CreatorUiHttpError(400, 'creator_ui_invalid_request', 'project must be a valid project slug.'); } return sendJson(response, 200, await buildCreatorUiProjectSnapshot(context.root, project)); } catch (error) { if (error instanceof CreatorUiHttpError) return sendApiError(response, error); throw error; } } if (url.pathname === '/api/capabilities') { try { if (request.method !== 'GET') throw new CreatorUiHttpError(405, 'creator_ui_method_not_allowed', 'Method not allowed.'); const capabilities: CreatorCapabilities = buildCreatorCapabilities({ root: context.root, ...(context.capabilityEnv ? { env: context.capabilityEnv } : {}), ...(context.capabilityNow ? { now: context.capabilityNow } : {}), ...(context.capabilityProbeExecutable ? { probeExecutable: context.capabilityProbeExecutable } : {}), }); return sendJson(response, 200, capabilities); } catch (error) { if (error instanceof CreatorUiHttpError) return sendApiError(response, error); throw error; } } if (url.pathname === '/api/plan') { try { if (request.method !== 'POST') throw new CreatorUiHttpError(405, 'creator_ui_method_not_allowed', 'Method not allowed.'); if (!mutationHasCsrfProof(request, context.host, context.port)) { throw new CreatorUiHttpError(403, 'creator_ui_csrf_rejected', 'Creator UI mutations require same-origin requests.'); } if (!isJsonRequest(request)) { throw new CreatorUiHttpError(415, 'creator_ui_json_required', 'Creator UI mutations require application/json.'); } const planRequest = parsePlanRequest(await readJsonBody(request)); const planned = await planResponse(context.root, planRequest, context.planEnv); rememberPlanRequest(context.planRequests, planned.planDigest, planRequest); return sendJson(response, 200, planned); } catch (error) { if (error instanceof CreatorUiHttpError) return sendApiError(response, error); throw error; } } if (url.pathname === '/api/execute') { try { if (request.method !== 'POST') throw new CreatorUiHttpError(405, 'creator_ui_method_not_allowed', 'Method not allowed.'); if (!mutationHasCsrfProof(request, context.host, context.port)) { throw new CreatorUiHttpError(403, 'creator_ui_csrf_rejected', 'Creator UI mutations require same-origin requests.'); } if (!isJsonRequest(request)) { throw new CreatorUiHttpError(415, 'creator_ui_json_required', 'Creator UI mutations require application/json.'); } const executeRequest = parseCreatorUiExecuteRequest(await readJsonBody(request)); const remembered = context.planRequests.get(executeRequest.planDigest); if (!remembered) { throw new CreatorUiExecutionError(409, 'creator_ui_plan_stale', 'Plan digest is unknown or expired; preview the plan again.'); } const rebuilt = await planResponse(context.root, remembered, context.planEnv); if (rebuilt.planDigest !== executeRequest.planDigest) { context.planRequests.delete(executeRequest.planDigest); throw new CreatorUiExecutionError(409, 'creator_ui_plan_stale', 'Project state changed; preview the plan again before execution.'); } const execution = runCreatorUiExecution(rebuilt.plan, executeRequest, { runStep: context.studioRunStep, ...(context.studioBaseEnv ? { baseEnv: context.studioBaseEnv } : {}), }); return sendJson(response, 200, { planDigest: rebuilt.planDigest, plan: rebuilt.plan, execution, artifactPaths: (rebuilt.plan.outputArtifacts ?? []).map((item) => item.path), }); } catch (error) { if (error instanceof CreatorUiExecutionError) { return sendApiError(response, new CreatorUiHttpError(error.statusCode, error.code, error.message)); } if (error instanceof CreatorUiHttpError) return sendApiError(response, error); throw error; } } if (url.pathname === '/api/stock-search' || url.pathname === '/api/stock-import') { try { if (request.method !== 'POST') throw new CreatorUiHttpError(405, 'creator_ui_method_not_allowed', 'Method not allowed.'); if (!mutationHasCsrfProof(request, context.host, context.port)) { throw new CreatorUiHttpError(403, 'creator_ui_csrf_rejected', 'Creator UI mutations require same-origin requests.'); } if (!isJsonRequest(request)) { throw new CreatorUiHttpError(415, 'creator_ui_json_required', 'Creator UI mutations require application/json.'); } const body = await readJsonBody(request); const options = { ...(context.stockFetch ? { stockFetch: context.stockFetch } : {}), ...(context.stockApiKey !== undefined ? { stockApiKey: context.stockApiKey } : {}), }; return sendJson( response, 200, url.pathname === '/api/stock-search' ? await stockSearchResponse(body, options) : await stockImportResponse(context.root, body, options), ); } catch (error) { if (error instanceof CreatorUiHttpError) return sendApiError(response, error); if (error instanceof VclawError) return sendVclawApiError(response, error); throw error; } } if (url.pathname.startsWith('/api/')) return sendJson(response, 404, { error: { code: 'creator_ui_not_found', message: 'Creator UI API route not found.' } }); return sendText(response, 403, 'Forbidden'); } export async function launchCreatorUi(options: CreatorUiOptions): Promise { const root = resolve(options.root); const host = options.host ?? '127.0.0.1'; const requestedPort = options.port ?? 4327; if (isWildcardHost(host)) { throw new VclawError('invalid_flag_value', 'creator-ui refuses wildcard bind addresses', { flag: '--host', value: host }); } if (!isLoopbackHost(host)) { throw new VclawError('invalid_flag_value', 'creator-ui binds loopback only; remote access is not supported', { flag: '--host', value: host }); } if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) { throw new VclawError('invalid_flag_value', 'creator-ui requires --port to be an integer between 0 and 65535', { flag: '--port', value: requestedPort }); } const authToken = randomBytes(32).toString('base64url'); const urlFor = (port: number) => `http://${formatHost(host)}:${port}/creator-ui?token=${encodeURIComponent(authToken)}`; const launch: CreatorUiLaunch = { url: urlFor(requestedPort), host, port: requestedPort, root, authRequired: true, remoteAccess: false, dryRun: options.dryRun ?? false, }; if (options.dryRun) return launch; const planRequests = new Map(); const planEnv = { ...(options.capabilityEnv ?? process.env) }; const server = createServer((request, response) => { handleRequest(request, response, { root, host, port: launch.port, authToken, ...(options.stockFetch ? { stockFetch: options.stockFetch } : {}), ...(options.stockApiKey !== undefined ? { stockApiKey: options.stockApiKey } : {}), ...(options.capabilityEnv ? { capabilityEnv: options.capabilityEnv } : {}), ...(options.capabilityNow ? { capabilityNow: options.capabilityNow } : {}), ...(options.capabilityProbeExecutable ? { capabilityProbeExecutable: options.capabilityProbeExecutable } : {}), studioRunStep: options.studioRunStep ?? defaultCreatorUiStudioRunner, ...(options.studioBaseEnv ? { studioBaseEnv: options.studioBaseEnv } : {}), planEnv, planRequests, }).catch((error: unknown) => { if (!response.headersSent) sendText(response, 500, error instanceof Error ? error.message : String(error)); else response.end(); }); }); await new Promise((resolveListen, rejectListen) => { server.once('error', rejectListen); server.listen(requestedPort, host, () => { server.off('error', rejectListen); const address = server.address(); if (address && typeof address === 'object') { launch.port = address.port; launch.url = urlFor(address.port); } resolveListen(); }); }); Object.defineProperty(launch, 'close', { enumerable: false, value: () => new Promise((resolveClose, rejectClose) => { server.close((error) => error ? rejectClose(error) : resolveClose()); }), }); return launch; }