feat: replace ai-cad with official HiCAD workspace
This commit is contained in:
parent
07ae16d0ed
commit
9cf84c324e
6
.gitignore
vendored
6
.gitignore
vendored
@ -90,5 +90,11 @@ reference-sources/
|
|||||||
# Legacy external Pascal Editor checkout (the managed source is under services/).
|
# Legacy external Pascal Editor checkout (the managed source is under services/).
|
||||||
.pascal-editor/
|
.pascal-editor/
|
||||||
|
|
||||||
|
# Local self-hosted Zoo Design Studio checkout.
|
||||||
|
.zoo-modeling-app/
|
||||||
|
|
||||||
|
# Generated official Zoo Design Studio web bundle.
|
||||||
|
public/vendor/zoo-modeling-app/
|
||||||
|
|
||||||
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
||||||
!lib/db/migrations/*.sql
|
!lib/db/migrations/*.sql
|
||||||
|
|||||||
@ -1,7 +1,33 @@
|
|||||||
import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { ZooDesignStudioFrame } from "@/components/ai-cad/zoo-design-studio-frame";
|
||||||
|
|
||||||
|
const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "HiCAD",
|
||||||
|
};
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export default function AiCadPage() {
|
export default async function AiCadPage() {
|
||||||
return <AiCadWorkbench />;
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
redirect("/login?callbackUrl=%2Fai-cad");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isReady = existsSync(
|
||||||
|
path.join(process.cwd(), "public/vendor/zoo-modeling-app/index.html")
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ZooDesignStudioFrame
|
||||||
|
isReady={isReady}
|
||||||
|
sourceLabel="HiCAD"
|
||||||
|
src={ZOO_MODELING_APP_SRC}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/api/ai-cad/zoo-ws-authorize/route.ts
Normal file
66
app/api/ai-cad/zoo-ws-authorize/route.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const ALLOWED_WEBSOCKET_PATHS = new Set([
|
||||||
|
"/api/ai-cad/zoo-ws/ml/copilot",
|
||||||
|
"/api/ai-cad/zoo-ws/modeling/commands",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function hasValidInternalSecret(request: Request) {
|
||||||
|
const expected = String(process.env.AI_CAD_WS_PROXY_SECRET || "").trim();
|
||||||
|
const received = String(
|
||||||
|
request.headers.get("x-hicad-ws-proxy-secret") || ""
|
||||||
|
).trim();
|
||||||
|
if (!expected || !received) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
const receivedBuffer = Buffer.from(received);
|
||||||
|
return (
|
||||||
|
expectedBuffer.length === receivedBuffer.length &&
|
||||||
|
timingSafeEqual(expectedBuffer, receivedBuffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!hasValidInternalSecret(request)) {
|
||||||
|
return NextResponse.json({ error: "禁止访问" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalUri = String(
|
||||||
|
request.headers.get("x-hicad-original-uri") || ""
|
||||||
|
).split("?", 1)[0];
|
||||||
|
if (!ALLOWED_WEBSOCKET_PATHS.has(originalUri)) {
|
||||||
|
return NextResponse.json({ error: "不支持的 Zoo WebSocket" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getKittyCadProviderConfig();
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"X-Zoo-Authorization": `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Zoo WebSocket 鉴权失败",
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
app/api/ai-cad/zoo/[...path]/route.ts
Normal file
140
app/api/ai-cad/zoo/[...path]/route.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const HOP_BY_HOP_HEADERS = new Set([
|
||||||
|
"connection",
|
||||||
|
"content-length",
|
||||||
|
"host",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BLOCKED_PATH_PREFIXES = [
|
||||||
|
"/admin",
|
||||||
|
"/internal",
|
||||||
|
"/logout",
|
||||||
|
"/oauth2",
|
||||||
|
"/org/api-tokens",
|
||||||
|
"/user/api-tokens",
|
||||||
|
"/users",
|
||||||
|
];
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ path: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBlockedPath(pathname: string) {
|
||||||
|
return BLOCKED_PATH_PREFIXES.some(
|
||||||
|
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyRequestHeaders(request: Request, apiKey: string) {
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
for (const [name, value] of request.headers.entries()) {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
if (
|
||||||
|
HOP_BY_HOP_HEADERS.has(normalizedName) ||
|
||||||
|
normalizedName === "authorization" ||
|
||||||
|
normalizedName === "cookie" ||
|
||||||
|
normalizedName === "origin" ||
|
||||||
|
normalizedName === "referer" ||
|
||||||
|
normalizedName.startsWith("x-forwarded-") ||
|
||||||
|
normalizedName === "x-hicad-ws-proxy-secret"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||||
|
headers.set("Accept", request.headers.get("accept") || "application/json");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyResponseHeaders(upstream: Response) {
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
for (const [name, value] of upstream.headers.entries()) {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
if (
|
||||||
|
HOP_BY_HOP_HEADERS.has(normalizedName) ||
|
||||||
|
normalizedName === "content-encoding" ||
|
||||||
|
normalizedName === "set-cookie"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set("Cache-Control", "private, no-store");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyZooRequest(request: Request, context: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path } = await context.params;
|
||||||
|
const pathname = `/${path.map(encodeURIComponent).join("/")}`;
|
||||||
|
if (isBlockedPath(pathname)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "该 Zoo API 不允许通过 HiCAD 代理访问" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getKittyCadProviderConfig();
|
||||||
|
const incomingUrl = new URL(request.url);
|
||||||
|
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||||
|
const upstreamUrl = `${baseUrl}${pathname}${incomingUrl.search}`;
|
||||||
|
const method = request.method.toUpperCase();
|
||||||
|
const hasBody = method !== "GET" && method !== "HEAD";
|
||||||
|
const init: RequestInit & { duplex?: "half" } = {
|
||||||
|
body: hasBody ? request.body : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
duplex: hasBody ? "half" : undefined,
|
||||||
|
headers: copyRequestHeaders(request, config.apiKey),
|
||||||
|
method,
|
||||||
|
redirect: "manual",
|
||||||
|
signal: request.signal,
|
||||||
|
};
|
||||||
|
const upstream = await fetch(upstreamUrl, init);
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
headers: copyResponseHeaders(upstream),
|
||||||
|
status: upstream.status,
|
||||||
|
statusText: upstream.statusText,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Zoo API 代理请求失败",
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DELETE = proxyZooRequest;
|
||||||
|
export const GET = proxyZooRequest;
|
||||||
|
export const HEAD = proxyZooRequest;
|
||||||
|
export const OPTIONS = proxyZooRequest;
|
||||||
|
export const PATCH = proxyZooRequest;
|
||||||
|
export const POST = proxyZooRequest;
|
||||||
|
export const PUT = proxyZooRequest;
|
||||||
64
components/ai-cad/zoo-design-studio-frame.tsx
Normal file
64
components/ai-cad/zoo-design-studio-frame.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type ZooDesignStudioFrameProps = {
|
||||||
|
isReady: boolean;
|
||||||
|
sourceLabel: string;
|
||||||
|
src: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ZooDesignStudioFrame({
|
||||||
|
isReady,
|
||||||
|
sourceLabel,
|
||||||
|
src,
|
||||||
|
}: ZooDesignStudioFrameProps) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-svh w-full items-center justify-center overflow-hidden bg-[#10100e] p-6 text-[#f8f6ef]">
|
||||||
|
<div className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-300" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="font-semibold text-lg">
|
||||||
|
HiCAD 官方前端尚未构建
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
|
||||||
|
请运行
|
||||||
|
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
|
||||||
|
pnpm zoo-modeling-app:vendor
|
||||||
|
</code>
|
||||||
|
生成官方 Zoo Design Studio 前端。生成后刷新本页即可使用。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-svh w-full overflow-hidden bg-[#10100e]">
|
||||||
|
{!loaded && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[#10100e] text-[#d3cabb]">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-[#3a352c] bg-[#171510]/95 px-4 py-3 shadow-xl">
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm">正在加载 {sourceLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<iframe
|
||||||
|
allow="clipboard-read; clipboard-write; fullscreen; gamepad; pointer-lock; web-share; xr-spatial-tracking"
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
sandbox="allow-downloads allow-forms allow-modals allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||||
|
src={src}
|
||||||
|
title="HiCAD"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
deploy/nginx-hicad-zoo-ws.conf.template
Normal file
35
deploy/nginx-hicad-zoo-ws.conf.template
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Include inside the authenticated HiCAD HTTPS server block.
|
||||||
|
# Replace __AI_CAD_WS_PROXY_SECRET__ during deployment and chmod the rendered file 600.
|
||||||
|
|
||||||
|
location = /_hicad_zoo_ws_auth {
|
||||||
|
internal;
|
||||||
|
proxy_pass http://127.0.0.1:3125/api/ai-cad/zoo-ws-authorize;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-HiCAD-Original-URI $request_uri;
|
||||||
|
proxy_set_header X-HiCAD-WS-Proxy-Secret "__AI_CAD_WS_PROXY_SECRET__";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /api/ai-cad/zoo-ws/ {
|
||||||
|
auth_request /_hicad_zoo_ws_auth;
|
||||||
|
auth_request_set $zoo_authorization $upstream_http_x_zoo_authorization;
|
||||||
|
|
||||||
|
proxy_pass https://api.zoo.dev/ws/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_ssl_name api.zoo.dev;
|
||||||
|
proxy_set_header Host api.zoo.dev;
|
||||||
|
proxy_set_header Origin https://app.zoo.dev;
|
||||||
|
proxy_set_header Authorization $zoo_authorization;
|
||||||
|
proxy_set_header Cookie "";
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
@ -14,6 +14,8 @@
|
|||||||
"pascal:deploy": "bash scripts/deploy-pascal-production.sh deploy",
|
"pascal:deploy": "bash scripts/deploy-pascal-production.sh deploy",
|
||||||
"pascal:check": "bash scripts/deploy-pascal-production.sh check",
|
"pascal:check": "bash scripts/deploy-pascal-production.sh check",
|
||||||
"pascal:smoke": "bash scripts/smoke-pascal-agent.sh",
|
"pascal:smoke": "bash scripts/smoke-pascal-agent.sh",
|
||||||
|
"zoo-modeling-app:vendor": "node scripts/vendor-zoo-modeling-app.mjs",
|
||||||
|
"zoo-modeling-app:ws-proxy": "node scripts/ai-cad-zoo-ws-dev-proxy.mjs",
|
||||||
"postinstall": "node scripts/security/auto-enable-perf-guard.mjs",
|
"postinstall": "node scripts/security/auto-enable-perf-guard.mjs",
|
||||||
"prebuild": "node scripts/security/auto-enable-perf-guard.mjs",
|
"prebuild": "node scripts/security/auto-enable-perf-guard.mjs",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
121
scripts/ai-cad-zoo-ws-dev-proxy.mjs
Normal file
121
scripts/ai-cad-zoo-ws-dev-proxy.mjs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createServer, request as httpRequest } from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import WebSocket, { WebSocketServer } from "ws";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
dotenv.config({ path: path.join(root, ".env.local") });
|
||||||
|
|
||||||
|
const host = "127.0.0.1";
|
||||||
|
const port = Number(process.env.HICAD_ZOO_WS_DEV_PROXY_PORT || 3010);
|
||||||
|
const appPort = Number(process.env.HICAD_APP_DEV_PORT || 3003);
|
||||||
|
const apiBaseUrl = String(
|
||||||
|
process.env.KITTYCAD_BASE_URL ||
|
||||||
|
process.env.ZOO_BASE_URL ||
|
||||||
|
"https://api.zoo.dev"
|
||||||
|
).replace(/\/+$/, "");
|
||||||
|
const apiKey = String(
|
||||||
|
process.env.KITTYCAD_API_TOKEN ||
|
||||||
|
process.env.KITTYCAD_API_KEY ||
|
||||||
|
process.env.ZOO_API_TOKEN ||
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.replace(/\s+/g, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("Missing KITTYCAD_API_TOKEN in .env.local");
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedPaths = new Map([
|
||||||
|
["/api/ai-cad/zoo-ws/ml/copilot", "/ws/ml/copilot"],
|
||||||
|
["/api/ai-cad/zoo-ws/modeling/commands", "/ws/modeling/commands"],
|
||||||
|
]);
|
||||||
|
const browserSockets = new WebSocketServer({ noServer: true });
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
const upstreamRequest = httpRequest(
|
||||||
|
{
|
||||||
|
headers: request.headers,
|
||||||
|
host,
|
||||||
|
method: request.method,
|
||||||
|
path: request.url,
|
||||||
|
port: appPort,
|
||||||
|
},
|
||||||
|
(upstreamResponse) => {
|
||||||
|
response.writeHead(
|
||||||
|
upstreamResponse.statusCode || 502,
|
||||||
|
upstreamResponse.headers
|
||||||
|
);
|
||||||
|
upstreamResponse.pipe(response);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
upstreamRequest.on("error", (error) => {
|
||||||
|
if (!response.headersSent) {
|
||||||
|
response.writeHead(502, {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
response.end(`HiCAD development proxy failed: ${error.message}`);
|
||||||
|
});
|
||||||
|
request.pipe(upstreamRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("upgrade", (request, socket, head) => {
|
||||||
|
const incomingUrl = new URL(request.url || "/", `http://${host}:${port}`);
|
||||||
|
const upstreamPath = allowedPaths.get(incomingUrl.pathname);
|
||||||
|
if (!upstreamPath) {
|
||||||
|
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
browserSockets.handleUpgrade(request, socket, head, (browserSocket) => {
|
||||||
|
const base = new URL(apiBaseUrl);
|
||||||
|
base.protocol = base.protocol === "http:" ? "ws:" : "wss:";
|
||||||
|
base.pathname = upstreamPath;
|
||||||
|
base.search = incomingUrl.search;
|
||||||
|
const upstreamSocket = new WebSocket(base, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
Origin: "https://app.zoo.dev",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeBoth = (code = 1011, reason = "Zoo connection closed") => {
|
||||||
|
if (browserSocket.readyState < WebSocket.CLOSING) {
|
||||||
|
browserSocket.close(code, reason);
|
||||||
|
}
|
||||||
|
if (upstreamSocket.readyState < WebSocket.CLOSING) {
|
||||||
|
upstreamSocket.close(code, reason);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
upstreamSocket.on("open", () => {
|
||||||
|
browserSocket.on("message", (data, isBinary) => {
|
||||||
|
if (upstreamSocket.readyState === WebSocket.OPEN) {
|
||||||
|
upstreamSocket.send(data, { binary: isBinary });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upstreamSocket.on("message", (data, isBinary) => {
|
||||||
|
if (browserSocket.readyState === WebSocket.OPEN) {
|
||||||
|
browserSocket.send(data, { binary: isBinary });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
browserSocket.on("close", () => closeBoth(1000, "Browser disconnected"));
|
||||||
|
browserSocket.on("error", () => closeBoth());
|
||||||
|
upstreamSocket.on("close", () => closeBoth(1000, "Zoo disconnected"));
|
||||||
|
upstreamSocket.on("error", (error) => {
|
||||||
|
console.error("[HiCAD Zoo WS proxy]", error.message);
|
||||||
|
closeBoth();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
console.log(`HiCAD development proxy: http://${host}:${port}`);
|
||||||
|
});
|
||||||
313
scripts/vendor-zoo-modeling-app.mjs
Normal file
313
scripts/vendor-zoo-modeling-app.mjs
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { cp } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const repoUrl = "https://github.com/KittyCAD/modeling-app.git";
|
||||||
|
const repoRef =
|
||||||
|
process.env.ZOO_MODELING_APP_REF ||
|
||||||
|
"6f87f4e66c07c67f09d8f7db284496a3ae3332e7";
|
||||||
|
const repoTarballUrls = [
|
||||||
|
`https://ghfast.top/https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`,
|
||||||
|
`https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`,
|
||||||
|
];
|
||||||
|
const root = process.cwd();
|
||||||
|
const workspaceDir = path.join(root, ".zoo-modeling-app");
|
||||||
|
const sourceDir = path.join(workspaceDir, "modeling-app");
|
||||||
|
const sourceRefFile = path.join(sourceDir, ".hicad-source-ref");
|
||||||
|
const outputDir = path.join(root, "public/vendor/zoo-modeling-app");
|
||||||
|
const proxyToken = "hicad-session";
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd: options.cwd || root,
|
||||||
|
env: { ...process.env, ...(options.env || {}) },
|
||||||
|
shell: false,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command} ${args.join(" ")} failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryRun(command, args, options = {}) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd: options.cwd || root,
|
||||||
|
env: { ...process.env, ...(options.env || {}) },
|
||||||
|
shell: false,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
return result.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadOfficialSource() {
|
||||||
|
mkdirSync(workspaceDir, { recursive: true });
|
||||||
|
const archivePath = path.join(workspaceDir, "modeling-app-main.tar.gz");
|
||||||
|
const extractedDir = path.join(workspaceDir, `modeling-app-${repoRef}`);
|
||||||
|
rmSync(archivePath, { force: true });
|
||||||
|
rmSync(extractedDir, { force: true, recursive: true });
|
||||||
|
|
||||||
|
const downloaded = repoTarballUrls.some((url) =>
|
||||||
|
tryRun("curl", [
|
||||||
|
"--http1.1",
|
||||||
|
"--location",
|
||||||
|
"--fail",
|
||||||
|
"--retry",
|
||||||
|
"3",
|
||||||
|
url,
|
||||||
|
"--output",
|
||||||
|
archivePath,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
if (!downloaded) {
|
||||||
|
throw new Error("Unable to download KittyCAD/modeling-app");
|
||||||
|
}
|
||||||
|
|
||||||
|
run("tar", ["-xzf", archivePath, "-C", workspaceDir]);
|
||||||
|
rmSync(sourceDir, { force: true, recursive: true });
|
||||||
|
run("mv", [extractedDir, sourceDir]);
|
||||||
|
writeFileSync(sourceRefFile, `${repoRef}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOfficialSource() {
|
||||||
|
if (existsSync(path.join(sourceDir, "package.json"))) {
|
||||||
|
if (!existsSync(sourceRefFile)) {
|
||||||
|
writeFileSync(sourceRefFile, `${repoRef}\n`);
|
||||||
|
}
|
||||||
|
const currentRef = readFileSync(sourceRefFile, "utf8").trim();
|
||||||
|
if (currentRef !== repoRef) {
|
||||||
|
downloadOfficialSource();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadOfficialSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSource(relativePath, before, after) {
|
||||||
|
const filePath = path.join(sourceDir, relativePath);
|
||||||
|
const source = readFileSync(filePath, "utf8");
|
||||||
|
if (after && source.includes(after)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!source.includes(before)) {
|
||||||
|
throw new Error(`Official source changed; patch failed for ${relativePath}`);
|
||||||
|
}
|
||||||
|
writeFileSync(filePath, source.replace(before, after));
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchOfficialSource() {
|
||||||
|
replaceSource(
|
||||||
|
"index.html",
|
||||||
|
' <meta name="description" content="An open-source CAD modeling tool from the future by Zoo." />',
|
||||||
|
' <meta name="description" content="HiCAD browser-based CAD workspace." />'
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"index.html",
|
||||||
|
' <script defer data-domain="app.zoo.dev" src="https://plausible.corp.zoo.dev/js/script.tagged-events.js"></script>\n',
|
||||||
|
""
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"index.html",
|
||||||
|
" <title>Zoo Design Studio</title>",
|
||||||
|
" <title>HiCAD</title>"
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/Router.tsx",
|
||||||
|
" createBrowserRouter,\n createHashRouter,",
|
||||||
|
" createHashRouter,"
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/Router.tsx",
|
||||||
|
"const createRouter = isDesktop() ? createHashRouter : createBrowserRouter",
|
||||||
|
"const createRouter = createHashRouter"
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/Router.tsx",
|
||||||
|
"import { isDesktop } from '@src/lib/isDesktop'\n",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/lib/socket.ts",
|
||||||
|
` ws.addEventListener('open', () => {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n resolve(ws)\n })`,
|
||||||
|
` ws.addEventListener('open', () => {\n if (token !== '${proxyToken}') {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n }\n resolve(ws)\n })`
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/network/websocketConnection.ts",
|
||||||
|
" if (token) {",
|
||||||
|
` if (token && token !== '${proxyToken}') {`
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/lib/withBaseURL.ts",
|
||||||
|
`export function withKittycadWebSocketURL(qs: string): string {\n return mergeQueryString(env().VITE_KITTYCAD_WEBSOCKET_URL, qs)\n}\n\nexport function withMlephantWebSocketURL(qs: string): string {\n return mergeQueryString(env().VITE_MLEPHANT_WEBSOCKET_URL, qs)\n}`,
|
||||||
|
`export function withKittycadWebSocketURL(qs: string): string {\n return mergeWebSocketQueryString(env().VITE_KITTYCAD_WEBSOCKET_URL, qs)\n}\n\nexport function withMlephantWebSocketURL(qs: string): string {\n return mergeWebSocketQueryString(env().VITE_MLEPHANT_WEBSOCKET_URL, qs)\n}\n\nfunction mergeWebSocketQueryString(\n url: string | undefined,\n qs: string\n): string {\n if (!url) return ''\n const absoluteUrl =\n typeof window !== 'undefined' && url.startsWith('/')\n ? \`\${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//\${window.location.host}\${url}\`\n : url\n return mergeQueryString(absoluteUrl, qs)\n}`
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/lang/wasmUtils.ts",
|
||||||
|
`export const wasmUrl = () => {\n const wasmFile = '/kcl_wasm_lib_bg.wasm'\n // Check for test environment override\n if (typeof process !== 'undefined' && process.env?.VITE_WASM_BASE_URL) {\n return process.env.VITE_WASM_BASE_URL + wasmFile\n }\n\n // For when we're in electron (file based) or web server (network based)\n // For some reason relative paths don't work as expected. Otherwise we would\n // just do /wasm_lib_bg.wasm. In particular, the issue arises when the path\n // is used from within worker.ts.\n const fullUrl = document.location.protocol.includes('http')\n ? document.location.origin + '/kcl_wasm_lib_bg.wasm'\n : document.location.protocol +\n webSafeJoin(webSafePathSplit(document.location.pathname).slice(0, -1)) +\n wasmFile\n\n return fullUrl\n}`,
|
||||||
|
`export const wasmUrl = () => {\n const wasmFile = 'kcl_wasm_lib_bg.wasm'\n if (typeof process !== 'undefined' && process.env?.VITE_WASM_BASE_URL) {\n return process.env.VITE_WASM_BASE_URL + '/' + wasmFile\n }\n\n return new URL(wasmFile, document.baseURI).toString()\n}`
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/lang/wasmUtils.ts",
|
||||||
|
"import { webSafeJoin, webSafePathSplit } from '@src/lib/paths'\n",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/components/UserSidebarMenu.tsx",
|
||||||
|
" const displayedName = getDisplayName(user)\n",
|
||||||
|
` const isHiCadEmbedded = env().VITE_ZOO_API_TOKEN === '${proxyToken}'\n const displayedName = isHiCadEmbedded ? 'HiCAD' : getDisplayName(user)\n`
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/components/UserSidebarMenu.tsx",
|
||||||
|
` [\n {\n id: 'account',`,
|
||||||
|
` [\n {\n id: 'hicad-home',\n Element: 'button',\n children: 'Back to HiCAD',\n onClick: () => window.top?.location.assign('/'),\n className: isHiCadEmbedded ? '' : 'hidden',\n },\n {\n id: 'account',`
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/components/UserSidebarMenu.tsx",
|
||||||
|
` ),\n },\n {\n id: 'settings',`,
|
||||||
|
` ),\n className: isHiCadEmbedded ? 'hidden' : '',\n },\n {\n id: 'settings',`
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/components/UserSidebarMenu.tsx",
|
||||||
|
" className: '',",
|
||||||
|
" className: isHiCadEmbedded ? 'hidden' : '',"
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/components/UserSidebarMenu.tsx",
|
||||||
|
" {user?.image && !imageLoadFailed ? (",
|
||||||
|
" {!isHiCadEmbedded && user?.image && !imageLoadFailed ? ("
|
||||||
|
);
|
||||||
|
replaceSource(
|
||||||
|
"src/components/UserSidebarMenu.tsx",
|
||||||
|
" {displayedName !== user.email && (",
|
||||||
|
" {!isHiCadEmbedded && displayedName !== user.email && ("
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDependencies() {
|
||||||
|
if (existsSync(path.join(sourceDir, "node_modules/.package-lock.json"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], {
|
||||||
|
cwd: sourceDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWasm() {
|
||||||
|
const wasmJs = path.join(
|
||||||
|
sourceDir,
|
||||||
|
"rust/kcl-wasm-lib/pkg/kcl_wasm_lib.js"
|
||||||
|
);
|
||||||
|
const wasmBinary = path.join(
|
||||||
|
sourceDir,
|
||||||
|
"rust/kcl-wasm-lib/pkg/kcl_wasm_lib_bg.wasm"
|
||||||
|
);
|
||||||
|
if (!(existsSync(wasmJs) && existsSync(wasmBinary))) {
|
||||||
|
const cargoEnv = {
|
||||||
|
CARGO_HTTP_MULTIPLEXING: "false",
|
||||||
|
CARGO_REGISTRIES_CRATES_IO_INDEX:
|
||||||
|
process.env.CARGO_REGISTRIES_CRATES_IO_INDEX ||
|
||||||
|
"sparse+https://rsproxy.cn/index/",
|
||||||
|
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: "sparse",
|
||||||
|
};
|
||||||
|
run(
|
||||||
|
"wasm-pack",
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"kcl-wasm-lib",
|
||||||
|
"--release",
|
||||||
|
"--target",
|
||||||
|
"web",
|
||||||
|
"--out-dir",
|
||||||
|
"pkg",
|
||||||
|
"--scope",
|
||||||
|
"kittycad",
|
||||||
|
"--no-opt",
|
||||||
|
],
|
||||||
|
{ cwd: path.join(sourceDir, "rust"), env: cargoEnv }
|
||||||
|
);
|
||||||
|
run(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"test",
|
||||||
|
"-p",
|
||||||
|
"kcl-lib",
|
||||||
|
"--features",
|
||||||
|
"artifact-graph",
|
||||||
|
"export_bindings",
|
||||||
|
],
|
||||||
|
{ cwd: path.join(sourceDir, "rust"), env: cargoEnv }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
copyFileSync(
|
||||||
|
path.join(sourceDir, "rust/kcl-wasm-lib/README.md"),
|
||||||
|
path.join(sourceDir, "rust/kcl-wasm-lib/pkg/README.md")
|
||||||
|
);
|
||||||
|
copyFileSync(wasmBinary, path.join(sourceDir, "public/kcl_wasm_lib_bg.wasm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
ensureOfficialSource();
|
||||||
|
ensureDependencies();
|
||||||
|
ensureWasm();
|
||||||
|
patchOfficialSource();
|
||||||
|
|
||||||
|
const buildEnv = {
|
||||||
|
VITE_KITTYCAD_WEBSOCKET_URL:
|
||||||
|
process.env.HICAD_ZOO_MODELING_WEBSOCKET_URL ||
|
||||||
|
"/api/ai-cad/zoo-ws/modeling/commands",
|
||||||
|
VITE_MLEPHANT_WEBSOCKET_URL:
|
||||||
|
process.env.HICAD_ZOO_AGENT_WEBSOCKET_URL ||
|
||||||
|
"/api/ai-cad/zoo-ws/ml/copilot",
|
||||||
|
VITE_ZOO_API_BASE_URL: "/api/ai-cad/zoo",
|
||||||
|
VITE_ZOO_API_TOKEN: proxyToken,
|
||||||
|
VITE_ZOO_BASE_DOMAIN: "zoo.dev",
|
||||||
|
};
|
||||||
|
rmSync(path.join(sourceDir, "build"), { force: true, recursive: true });
|
||||||
|
run("npx", ["vite", "build", "--base=./", "--mode=production"], {
|
||||||
|
cwd: sourceDir,
|
||||||
|
env: buildEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildDir = path.join(sourceDir, "build");
|
||||||
|
if (!existsSync(path.join(buildDir, "index.html"))) {
|
||||||
|
throw new Error("Zoo Design Studio build did not produce index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(outputDir, { force: true, recursive: true });
|
||||||
|
mkdirSync(path.dirname(outputDir), { recursive: true });
|
||||||
|
await cp(buildDir, outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const license = path.join(sourceDir, "LICENSE");
|
||||||
|
if (existsSync(license)) {
|
||||||
|
await cp(license, path.join(outputDir, "LICENSE"));
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
path.join(outputDir, "SOURCE.txt"),
|
||||||
|
[
|
||||||
|
"HiCAD web frontend based on Zoo Design Studio",
|
||||||
|
`Source: ${repoUrl}`,
|
||||||
|
`Source commit: ${repoRef}`,
|
||||||
|
"License: MIT",
|
||||||
|
"Authentication: HiCAD session with server-side Zoo proxy",
|
||||||
|
"",
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`HiCAD official frontend copied to ${outputDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user