mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
initial code (#2)
* feat: initial code port from hyperframes-internal Port all OSS-ready packages from the internal monorepo: - @hyperframes/core — shared types, HTML generation, GSAP utilities, runtime - @hyperframes/cli — CLI for creating, previewing, and rendering compositions - @hyperframes/engine — framework-agnostic rendering engine (BeginFrame + FFmpeg) - @hyperframes/producer — video rendering pipeline (Puppeteer + FFmpeg) - @hyperframes/ui-player — browser-based video player component - @hyperframes/studio — composition editor (React frontend + Hono backend) Includes regression test suite with Docker-based test harness. All HeyGen-internal references, deployment infrastructure, and proprietary assets have been removed. Package names migrated from @app/* to @hyperframes/*. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: scrub internal codenames and stale references from OSS port - Replace static.heygen.ai runtime URLs in test fixtures - Remove internal CDN publish script (publish-hyperframe-runtime.ts) - Replace sandbox-studio, sandbox-interceptor, __magicEditRuntime with neutral names (studio, hyperframe-runtime, __hyperframeRuntime) - Fix stale Vault API / localhost references in docs - Remove broken deprecated_studio link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove remaining internal codenames and stale references - Delete stale producer README.md and PIPELINE.md (referenced nonexistent files) - Replace "Cerberus" codename with "HyperFrames" in test design reviews - Replace magic-edit postMessage identifiers with hf-preview/hf-parent - Rename debug-magic-edit-timeline.ts to debug-timeline.ts - Replace "Motion Cut" with "HyperFrames" in Timeline comments - Fix studio/CLI references to nonexistent archive package (use local data/projects/ dir, stub render proxy) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
10621e7903
commit
9f8e5ba5a1
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @hyperframes/producer — Public Server
|
||||
*
|
||||
* Clean HTTP API for rendering HTML compositions to video.
|
||||
*
|
||||
* Routes:
|
||||
* POST /render — blocking render, returns JSON
|
||||
* POST /render/stream — SSE streaming render with progress
|
||||
* POST /lint — blocking Hyperframe lint
|
||||
* GET /health — health check
|
||||
* GET /outputs/:token — download rendered MP4
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, statSync, mkdtempSync, writeFileSync, rmSync, createReadStream } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { parseArgs } from "node:util";
|
||||
import crypto from "node:crypto";
|
||||
import { Hono, type Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { serve } from "@hono/node-server";
|
||||
import {
|
||||
RenderCancelledError,
|
||||
createRenderJob,
|
||||
executeRenderJob,
|
||||
} from "./services/renderOrchestrator.js";
|
||||
import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js";
|
||||
import { resolveRenderPaths } from "./utils/paths.js";
|
||||
import { defaultLogger, type ProducerLogger } from "./logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface HandlerOptions {
|
||||
/** Custom logger. Defaults to console-based defaultLogger. */
|
||||
logger?: ProducerLogger;
|
||||
/** Extract or generate a request ID. Defaults to x-request-id header or random UUID. */
|
||||
getRequestId?: (c: Context) => string;
|
||||
/** Directory for rendered output files. Defaults to PRODUCER_RENDERS_DIR or /tmp. */
|
||||
rendersDir?: string;
|
||||
/** Prefix for output URLs in responses. Default: "/outputs". */
|
||||
outputUrlPrefix?: string;
|
||||
/** TTL for output artifact download tokens (ms). Default: 15 minutes. */
|
||||
artifactTtlMs?: number;
|
||||
}
|
||||
|
||||
export interface ServerOptions extends HandlerOptions {
|
||||
/** Port to listen on. Default: 9847. */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
interface RenderInput {
|
||||
projectDir: string;
|
||||
outputPath?: string | null;
|
||||
fps: 24 | 30 | 60;
|
||||
quality: "draft" | "standard" | "high";
|
||||
workers?: number;
|
||||
useGpu: boolean;
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
interface PreparedRenderInput {
|
||||
input: RenderInput;
|
||||
cleanupProjectDir?: string;
|
||||
}
|
||||
|
||||
function parseRenderOptions(body: Record<string, unknown>): Omit<RenderInput, "projectDir"> {
|
||||
const fps = ([24, 30, 60].includes(body.fps as number) ? body.fps : 30) as 24 | 30 | 60;
|
||||
const quality = (["draft", "standard", "high"].includes(body.quality as string) ? body.quality : "high") as "draft" | "standard" | "high";
|
||||
const workers = typeof body.workers === "number" ? body.workers : undefined;
|
||||
const useGpu = body.gpu === true;
|
||||
const debug = body.debug === true;
|
||||
const outputPath =
|
||||
typeof body.outputPath === "string" && body.outputPath.trim().length > 0
|
||||
? body.outputPath
|
||||
: typeof body.output === "string" && body.output.trim().length > 0
|
||||
? body.output
|
||||
: null;
|
||||
|
||||
return { outputPath, fps, quality, workers, useGpu, debug };
|
||||
}
|
||||
|
||||
async function prepareRenderBody(body: Record<string, unknown>): Promise<{ prepared: PreparedRenderInput } | { error: string }> {
|
||||
const options = parseRenderOptions(body);
|
||||
const projectDir = typeof body.projectDir === "string" ? body.projectDir : undefined;
|
||||
if (projectDir) {
|
||||
const absProjectDir = resolve(projectDir);
|
||||
if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) {
|
||||
return { error: `Project directory not found: ${absProjectDir}` };
|
||||
}
|
||||
if (!existsSync(resolve(absProjectDir, "index.html"))) {
|
||||
return { error: `No index.html in project directory: ${absProjectDir}` };
|
||||
}
|
||||
return { prepared: { input: { projectDir: absProjectDir, ...options } } };
|
||||
}
|
||||
|
||||
const previewUrl = typeof body.previewUrl === "string" ? body.previewUrl.trim() : "";
|
||||
const inlineHtml = typeof body.html === "string" ? body.html : "";
|
||||
if (!previewUrl && !inlineHtml) {
|
||||
return { error: "Missing render source: provide projectDir, previewUrl, or html" };
|
||||
}
|
||||
|
||||
let htmlContent = inlineHtml;
|
||||
if (!htmlContent) {
|
||||
try {
|
||||
const response = await fetch(previewUrl, { method: "GET" });
|
||||
if (!response.ok) {
|
||||
return { error: `Failed to fetch previewUrl: ${response.status} ${response.statusText}` };
|
||||
}
|
||||
htmlContent = await response.text();
|
||||
} catch (error) {
|
||||
return { error: `Failed to fetch previewUrl: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir();
|
||||
const tempProjectDir = mkdtempSync(join(tempRoot, "producer-project-"));
|
||||
writeFileSync(join(tempProjectDir, "index.html"), htmlContent, "utf-8");
|
||||
return {
|
||||
prepared: {
|
||||
input: {
|
||||
projectDir: tempProjectDir,
|
||||
...options,
|
||||
},
|
||||
cleanupProjectDir: tempProjectDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOutputPath(projectDir: string, outputCandidate: string | null | undefined, rendersDir: string, log: ProducerLogger): string {
|
||||
try {
|
||||
return resolveRenderPaths(projectDir, outputCandidate, rendersDir).absoluteOutputPath;
|
||||
} catch (error) {
|
||||
const fallbackPath = resolve(rendersDir, `producer-fallback-${Date.now()}.mp4`);
|
||||
log.warn("Failed to resolve output path, using fallback", {
|
||||
fallback: fallbackPath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return fallbackPath;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output artifact management
|
||||
// ---------------------------------------------------------------------------
|
||||
interface OutputArtifact {
|
||||
path: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
function createArtifactStore(ttlMs: number) {
|
||||
const artifacts = new Map<string, OutputArtifact>();
|
||||
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, artifact] of artifacts.entries()) {
|
||||
if (artifact.expiresAtMs <= now) {
|
||||
artifacts.delete(token);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
cleanup.unref();
|
||||
|
||||
return {
|
||||
register(path: string): string {
|
||||
const token = crypto.randomUUID();
|
||||
artifacts.set(token, { path, expiresAtMs: Date.now() + ttlMs });
|
||||
return token;
|
||||
},
|
||||
get(token: string): OutputArtifact | undefined {
|
||||
return artifacts.get(token);
|
||||
},
|
||||
delete(token: string) {
|
||||
artifacts.delete(token);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupTempDir(dir: string | undefined, log: ProducerLogger): void {
|
||||
if (!dir) return;
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
log.warn("Failed to cleanup temp project dir", {
|
||||
cleanupProjectDir: dir,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler factory
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface RenderHandlers {
|
||||
render: (c: Context) => Promise<Response>;
|
||||
renderStream: (c: Context) => Response | Promise<Response>;
|
||||
lint: (c: Context) => Promise<Response>;
|
||||
health: (c: Context) => Response;
|
||||
outputs: (c: Context) => Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create route handler functions for the producer server.
|
||||
*
|
||||
* These can be mounted on any Hono app at any path prefix.
|
||||
*/
|
||||
export function createRenderHandlers(options: HandlerOptions = {}): RenderHandlers {
|
||||
const log = options.logger ?? defaultLogger;
|
||||
const getRequestId = options.getRequestId ?? ((c: Context) => c.req.header("x-request-id") || crypto.randomUUID());
|
||||
const outputUrlPrefix = options.outputUrlPrefix ?? "/outputs";
|
||||
const rendersDir = options.rendersDir ?? process.env.PRODUCER_RENDERS_DIR ?? "/tmp";
|
||||
const artifactTtlMs = options.artifactTtlMs ?? Number(process.env.PRODUCER_OUTPUT_ARTIFACT_TTL_MS || 15 * 60 * 1000);
|
||||
const store = createArtifactStore(artifactTtlMs);
|
||||
const startTime = Date.now();
|
||||
|
||||
const health = (c: Context): Response =>
|
||||
c.json({ status: "ok", uptime: Math.floor((Date.now() - startTime) / 1000), timestamp: new Date().toISOString() });
|
||||
|
||||
const lint = async (c: Context): Promise<Response> => {
|
||||
const requestId = getRequestId(c);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, requestId, error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const preparedResult = prepareHyperframeLintBody(body);
|
||||
if ("error" in preparedResult) {
|
||||
return c.json({ success: false, requestId, error: preparedResult.error }, 400);
|
||||
}
|
||||
|
||||
const result = runHyperframeLint(preparedResult.prepared);
|
||||
log.info("lint completed", {
|
||||
requestId,
|
||||
entryFile: preparedResult.prepared.entryFile,
|
||||
source: preparedResult.prepared.source,
|
||||
errorCount: result.errorCount,
|
||||
warningCount: result.warningCount,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
requestId,
|
||||
entryFile: preparedResult.prepared.entryFile,
|
||||
source: preparedResult.prepared.source,
|
||||
result,
|
||||
});
|
||||
};
|
||||
|
||||
const render = async (c: Context): Promise<Response> => {
|
||||
const requestId = getRequestId(c);
|
||||
const t0 = Date.now();
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, requestId, error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const preparedResult = await prepareRenderBody(body);
|
||||
if ("error" in preparedResult) {
|
||||
return c.json({ success: false, requestId, error: preparedResult.error }, 400);
|
||||
}
|
||||
|
||||
const { input, cleanupProjectDir } = preparedResult.prepared;
|
||||
const absoluteOutputPath = resolveOutputPath(input.projectDir, input.outputPath, rendersDir, log);
|
||||
const outputDir = dirname(absoluteOutputPath);
|
||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
log.info("render started", { requestId, projectDir: input.projectDir, fps: input.fps, quality: input.quality });
|
||||
|
||||
const job = createRenderJob({ fps: input.fps, quality: input.quality, workers: input.workers, useGpu: input.useGpu, debug: input.debug, logger: log });
|
||||
|
||||
let lastLoggedPct = -10;
|
||||
try {
|
||||
await executeRenderJob(job, input.projectDir, absoluteOutputPath, async (j, message) => {
|
||||
const pct = Math.floor(j.progress * 100);
|
||||
if (pct >= lastLoggedPct + 10) {
|
||||
lastLoggedPct = pct;
|
||||
log.info(`render progress ${pct}%`, { requestId, stage: j.currentStage, message });
|
||||
}
|
||||
});
|
||||
|
||||
const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0;
|
||||
const durationMs = Date.now() - t0;
|
||||
const outputToken = store.register(absoluteOutputPath);
|
||||
const outputUrl = `${outputUrlPrefix}/${outputToken}`;
|
||||
log.info("render completed", { requestId, durationMs, fileSize, perf: job.perfSummary ?? null });
|
||||
|
||||
return c.json({ success: true, requestId, outputPath: absoluteOutputPath, outputToken, outputUrl, fileSize, durationMs, videoDurationSeconds: job.duration ?? null, perf: job.perfSummary ?? null });
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - t0;
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
log.error("render failed", { requestId, durationMs, error: errorMsg, stage: job.currentStage });
|
||||
return c.json({ success: false, requestId, error: errorMsg, stage: job.currentStage, durationMs, errorDetails: job.errorDetails ?? null }, 500);
|
||||
} finally {
|
||||
cleanupTempDir(cleanupProjectDir, log);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStream = (c: Context) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const requestId = getRequestId(c);
|
||||
const t0 = Date.now();
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
await stream.writeSSE({ data: JSON.stringify({ type: "error", requestId, error: "Invalid JSON body", stage: "validation" }) });
|
||||
return;
|
||||
}
|
||||
|
||||
const preparedResult = await prepareRenderBody(body);
|
||||
if ("error" in preparedResult) {
|
||||
await stream.writeSSE({ data: JSON.stringify({ type: "error", requestId, error: preparedResult.error, stage: "validation" }) });
|
||||
return;
|
||||
}
|
||||
|
||||
const { input, cleanupProjectDir } = preparedResult.prepared;
|
||||
const absoluteOutputPath = resolveOutputPath(input.projectDir, input.outputPath, rendersDir, log);
|
||||
const outputDir = dirname(absoluteOutputPath);
|
||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
log.info("render-stream started", { requestId, projectDir: input.projectDir });
|
||||
|
||||
const job = createRenderJob({ fps: input.fps, quality: input.quality, workers: input.workers, useGpu: input.useGpu, debug: input.debug, logger: log });
|
||||
const abortController = new AbortController();
|
||||
const onRequestAbort = () => abortController.abort(new RenderCancelledError("request_aborted"));
|
||||
c.req.raw.signal.addEventListener("abort", onRequestAbort, { once: true });
|
||||
|
||||
try {
|
||||
await executeRenderJob(job, input.projectDir, absoluteOutputPath, async (j, message) => {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "progress",
|
||||
requestId,
|
||||
stage: j.currentStage,
|
||||
progress: j.progress,
|
||||
framesRendered: j.framesRendered ?? 0,
|
||||
totalFrames: j.totalFrames ?? 0,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
}, abortController.signal);
|
||||
|
||||
const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0;
|
||||
const outputToken = store.register(absoluteOutputPath);
|
||||
const outputUrl = `${outputUrlPrefix}/${outputToken}`;
|
||||
log.info("render-stream completed", { requestId, fileSize, perf: job.perfSummary ?? null });
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ type: "complete", requestId, outputPath: absoluteOutputPath, outputToken, outputUrl, fileSize, videoDurationSeconds: job.duration ?? null, perf: job.perfSummary ?? null }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RenderCancelledError) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "cancelled",
|
||||
requestId,
|
||||
stage: job.currentStage,
|
||||
message: error.message,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const elapsedMs = Date.now() - t0;
|
||||
log.error("render-stream failed", { requestId, elapsedMs, error: errorMsg, stage: job.currentStage });
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "error",
|
||||
requestId,
|
||||
error: errorMsg,
|
||||
stage: job.currentStage,
|
||||
elapsedMs,
|
||||
errorDetails: job.errorDetails ?? null,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
c.req.raw.signal.removeEventListener("abort", onRequestAbort);
|
||||
cleanupTempDir(cleanupProjectDir, log);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const outputs = (c: Context): Response => {
|
||||
const token = c.req.param("token");
|
||||
const artifact = store.get(token);
|
||||
if (!artifact) {
|
||||
return c.json({ success: false, error: "Output artifact not found or expired" }, 404);
|
||||
}
|
||||
if (!existsSync(artifact.path)) {
|
||||
store.delete(token);
|
||||
return c.json({ success: false, error: "Output artifact file missing" }, 404);
|
||||
}
|
||||
const stats = statSync(artifact.path);
|
||||
return new Response(createReadStream(artifact.path) as unknown as ReadableStream, {
|
||||
headers: {
|
||||
"content-type": "video/mp4",
|
||||
"content-length": String(stats.size),
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { render, renderStream, lint, health, outputs };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public app factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a Hono app with clean public routes for OSS use.
|
||||
*/
|
||||
export function createProducerApp(options: HandlerOptions = {}): Hono {
|
||||
const app = new Hono();
|
||||
const handlers = createRenderHandlers(options);
|
||||
|
||||
app.get("/health", handlers.health);
|
||||
app.post("/render", handlers.render);
|
||||
app.post("/render/stream", handlers.renderStream);
|
||||
app.post("/lint", handlers.lint);
|
||||
app.get("/outputs/:token", handlers.outputs);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start the producer HTTP server with graceful shutdown.
|
||||
*/
|
||||
export function startServer(options: ServerOptions = {}) {
|
||||
const port = options.port ?? parseInt(process.env.PRODUCER_PORT ?? "9847", 10);
|
||||
const log = options.logger ?? defaultLogger;
|
||||
const app = createProducerApp(options);
|
||||
|
||||
const server = serve({ fetch: app.fetch, port }, () => {
|
||||
log.info(`Listening on http://localhost:${port}`);
|
||||
});
|
||||
|
||||
// Disable timeouts for long renders
|
||||
server.setTimeout(0);
|
||||
(server as unknown as import("node:http").Server).requestTimeout = 0;
|
||||
(server as unknown as import("node:http").Server).keepAliveTimeout = 0;
|
||||
|
||||
function shutdown(signal: string) {
|
||||
log.info(`Received ${signal}, shutting down`);
|
||||
server.close(() => {
|
||||
log.info("Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => {
|
||||
log.warn("Forced exit after 30s timeout");
|
||||
process.exit(1);
|
||||
}, 30_000).unref();
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-executable: node dist/public-server.js
|
||||
// ---------------------------------------------------------------------------
|
||||
// Only auto-start when this file is the explicit entry point.
|
||||
// In esbuild bundles, import.meta.url is shared across inlined modules,
|
||||
// so we check argv[1] against known public server filenames.
|
||||
const entryScript = process.argv[1] ? resolve(process.argv[1]) : "";
|
||||
const isPublicServerEntry = entryScript.endsWith("/public-server.js") ||
|
||||
entryScript.endsWith("/src/server.ts");
|
||||
|
||||
if (isPublicServerEntry) {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
port: { type: "string", short: "p", default: process.env.PRODUCER_PORT ?? "9847" },
|
||||
},
|
||||
});
|
||||
startServer({ port: parseInt(values.port as string, 10) });
|
||||
}
|
||||
Reference in New Issue
Block a user