Gateway: ship Ironclaw web app as a managed sidecar
- Add gateway.webApp config (enabled, port, dev) as the unified toggle for both the Next.js web UI and the built-in control UI - Spawn Next.js app alongside the gateway; stop it on shutdown - Auto-enable webApp in config for new and existing installs - Pre-build Next.js in deploy.sh and ship .next/ in the npm package so installed users get instant startup (no build step) - Gateway skips build when pre-built .next/ exists; builds on first run for dev/git-checkout users - Onboarding "Open the Web UI" now opens the Ironclaw web app - Fix pre-existing Next.js build errors (ES2023 lib, Tiptap v3 types, Suspense boundary, ReportConfig type alignment) - Rename deploy target from openclaw-ai-sdk to ironclaw Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
dfaf7180b4
commit
a1a54403a5
@ -399,7 +399,7 @@ export function MarkdownEditor({
|
||||
<EditorToolbar editor={editor} onUploadImages={insertUploadedImages} />
|
||||
|
||||
{/* Bubble menu for text selection */}
|
||||
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
|
||||
<BubbleMenu editor={editor}>
|
||||
<div className="bubble-menu">
|
||||
<BubbleButton
|
||||
active={editor.isActive("bold")}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
|
||||
import { useState, useCallback } from "react";
|
||||
import type { ReportConfig } from "../../components/charts/types";
|
||||
|
||||
@ -30,20 +30,16 @@ function ReportBlockView({
|
||||
updateAttributes,
|
||||
deleteNode,
|
||||
selected,
|
||||
}: {
|
||||
node: { attrs: { config: string } };
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void;
|
||||
deleteNode: () => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
}: ReactNodeViewProps) {
|
||||
const configAttr = node.attrs.config as string;
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [editValue, setEditValue] = useState(node.attrs.config);
|
||||
const [editValue, setEditValue] = useState(configAttr);
|
||||
|
||||
let parsedConfig: ReportConfig | null = null;
|
||||
let parseError: string | null = null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(node.attrs.config);
|
||||
const parsed = JSON.parse(configAttr);
|
||||
if (parsed?.panels && Array.isArray(parsed.panels)) {
|
||||
parsedConfig = parsed as ReportConfig;
|
||||
} else {
|
||||
@ -76,7 +72,7 @@ function ReportBlockView({
|
||||
if (showSource) {
|
||||
handleSaveSource();
|
||||
} else {
|
||||
setEditValue(node.attrs.config);
|
||||
setEditValue(configAttr);
|
||||
setShowSource(true);
|
||||
}
|
||||
}}
|
||||
@ -128,7 +124,7 @@ function ReportBlockView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditValue(node.attrs.config);
|
||||
setEditValue(configAttr);
|
||||
setShowSource(true);
|
||||
}}
|
||||
className="report-block-btn"
|
||||
|
||||
@ -460,7 +460,7 @@ function SlashPopupRenderer({
|
||||
}: {
|
||||
items: SlashItem[];
|
||||
command: (item: SlashItem) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
clientRect: (() => DOMRect | null) | null | undefined;
|
||||
componentRef: React.RefObject<CommandListRef | null>;
|
||||
}) {
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
@ -497,7 +497,7 @@ function createSuggestionRenderer() {
|
||||
onStart: (props: {
|
||||
items: SlashItem[];
|
||||
command: (item: SlashItem) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
clientRect?: (() => DOMRect | null) | null;
|
||||
}) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@ -517,7 +517,7 @@ function createSuggestionRenderer() {
|
||||
onUpdate: (props: {
|
||||
items: SlashItem[];
|
||||
command: (item: SlashItem) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
clientRect?: (() => DOMRect | null) | null;
|
||||
}) => {
|
||||
root?.render(
|
||||
<SlashPopupRenderer
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||
import { Suspense, useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { WorkspaceSidebar } from "../components/workspace/workspace-sidebar";
|
||||
import { type TreeNode } from "../components/workspace/file-manager-tree";
|
||||
@ -171,6 +171,18 @@ function resolveNode(
|
||||
// --- Main Page ---
|
||||
|
||||
export default function WorkspacePage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex h-screen items-center justify-center" style={{ background: "var(--color-bg)" }}>
|
||||
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }} />
|
||||
</div>
|
||||
}>
|
||||
<WorkspacePageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacePageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const initialPathHandled = useRef(false);
|
||||
|
||||
@ -3,26 +3,9 @@
|
||||
* Extracted from chat-message.tsx for testability.
|
||||
*/
|
||||
|
||||
export type ReportConfig = {
|
||||
version: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
panels: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
sql: string;
|
||||
mapping: Record<string, unknown>;
|
||||
size?: string;
|
||||
}>;
|
||||
filters?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
column: string;
|
||||
sql?: string;
|
||||
}>;
|
||||
};
|
||||
import type { ReportConfig } from "../app/components/charts/types";
|
||||
|
||||
export type { ReportConfig };
|
||||
|
||||
export type ParsedSegment =
|
||||
| { type: "text"; text: string }
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Produce a self-contained build in .next/standalone so the npm package
|
||||
// can run `node .next/standalone/server.js` without a full node_modules.
|
||||
output: "standalone",
|
||||
|
||||
// Allow long-running API routes for agent streaming
|
||||
serverExternalPackages: [],
|
||||
|
||||
|
||||
130
apps/web/package-lock.json
generated
130
apps/web/package-lock.json
generated
@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "openclaw-web",
|
||||
"name": "ironclaw-web",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openclaw-web",
|
||||
"name": "ironclaw-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.75",
|
||||
@ -33,6 +33,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-is": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
@ -110,6 +111,7 @@
|
||||
},
|
||||
"../../node_modules/.pnpm/@types+react-dom@19.1.5_@types+react@19.1.4/node_modules/@types/react-dom": {
|
||||
"version": "19.1.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
@ -117,6 +119,7 @@
|
||||
},
|
||||
"../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react": {
|
||||
"version": "19.1.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@ -4243,8 +4246,7 @@
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
@ -4958,126 +4960,6 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.3.tgz",
|
||||
"integrity": "sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.3.tgz",
|
||||
"integrity": "sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.3.tgz",
|
||||
"integrity": "sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.3.tgz",
|
||||
"integrity": "sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.3.tgz",
|
||||
"integrity": "sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.3.tgz",
|
||||
"integrity": "sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.3.tgz",
|
||||
"integrity": "sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.3.tgz",
|
||||
"integrity": "sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-is": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"target": "ES2023",
|
||||
"lib": ["dom", "dom.iterable", "ES2023"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ironclaw",
|
||||
"version": "2026.2.6-3",
|
||||
"version": "2026.2.6-3.4",
|
||||
"description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
@ -9,6 +9,11 @@
|
||||
"ironclaw": "openclaw.mjs"
|
||||
},
|
||||
"files": [
|
||||
"apps/web/.next/standalone/",
|
||||
"apps/web/.next/static/",
|
||||
"apps/web/public/",
|
||||
"apps/web/package.json",
|
||||
"apps/web/next.config.ts",
|
||||
"assets/",
|
||||
"CHANGELOG.md",
|
||||
"dist/",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — build and publish openclaw-ai-sdk to npm
|
||||
# deploy.sh — build and publish ironclaw to npm
|
||||
#
|
||||
# Versioning convention (mirrors upstream openclaw tags):
|
||||
# --upstream <ver> Sync to an upstream release version.
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE_NAME="openclaw-ai-sdk"
|
||||
PACKAGE_NAME="ironclaw"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
@ -176,12 +176,17 @@ npm version "$VERSION" --no-git-tag-version --allow-same-version "${NPM_FLAGS[@]
|
||||
if [[ "$SKIP_BUILD" != true ]]; then
|
||||
echo "building..."
|
||||
pnpm build
|
||||
|
||||
# Build the Ironclaw Next.js web app so the npm package ships a ready-to-run
|
||||
# production build (gateway can skip the build step at startup).
|
||||
echo "building web app..."
|
||||
(cd apps/web && npm install --legacy-peer-deps && npx next build)
|
||||
fi
|
||||
|
||||
# ── publish ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Always tag as "latest" — npm skips the latest tag for prerelease versions
|
||||
# by default, but we want `npm i -g openclaw-ai-sdk` to always resolve to
|
||||
# by default, but we want `npm i -g ironclaw` to always resolve to
|
||||
# the most recently published version.
|
||||
echo "publishing ${PACKAGE_NAME}@${VERSION}..."
|
||||
npm publish --access public --tag latest "${NPM_FLAGS[@]}"
|
||||
|
||||
@ -99,6 +99,7 @@ export function applyNonInteractiveGatewayConfig(params: {
|
||||
mode: tailscaleMode,
|
||||
resetOnExit: tailscaleResetOnExit,
|
||||
},
|
||||
webApp: { enabled: true },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -211,6 +211,15 @@ export type GatewayNodesConfig = {
|
||||
denyCommands?: string[];
|
||||
};
|
||||
|
||||
export type GatewayWebAppConfig = {
|
||||
/** If true, the Gateway will build and serve the Ironclaw Next.js web app. Default: false. */
|
||||
enabled?: boolean;
|
||||
/** Port for the Next.js web app (default: 3100). */
|
||||
port?: number;
|
||||
/** Run in dev mode (`next dev`) instead of production (`next build && next start`). Default: false. */
|
||||
dev?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayConfig = {
|
||||
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
|
||||
port?: number;
|
||||
@ -245,4 +254,6 @@ export type GatewayConfig = {
|
||||
* `x-real-ip`) to determine the client IP for local pairing and HTTP checks.
|
||||
*/
|
||||
trustedProxies?: string[];
|
||||
/** Ironclaw Next.js web app served alongside the gateway. */
|
||||
webApp?: GatewayWebAppConfig;
|
||||
};
|
||||
|
||||
@ -508,6 +508,14 @@ export const OpenClawSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
webApp: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
port: z.number().int().positive().optional(),
|
||||
dev: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@ -3,6 +3,7 @@ import type { WebSocketServer } from "ws";
|
||||
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
|
||||
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||
import type { PluginServicesHandle } from "../plugins/services.js";
|
||||
import type { WebAppHandle } from "./server-web-app.js";
|
||||
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||
|
||||
@ -26,6 +27,7 @@ export function createGatewayCloseHandler(params: {
|
||||
clients: Set<{ socket: { close: (code: number, reason: string) => void } }>;
|
||||
configReloader: { stop: () => Promise<void> };
|
||||
browserControl: { stop: () => Promise<void> } | null;
|
||||
webApp: WebAppHandle | null;
|
||||
wss: WebSocketServer;
|
||||
httpServer: HttpServer;
|
||||
httpServers?: HttpServer[];
|
||||
@ -108,6 +110,9 @@ export function createGatewayCloseHandler(params: {
|
||||
if (params.browserControl) {
|
||||
await params.browserControl.stop().catch(() => {});
|
||||
}
|
||||
if (params.webApp) {
|
||||
await params.webApp.stop().catch(() => {});
|
||||
}
|
||||
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
|
||||
const servers =
|
||||
params.httpServers && params.httpServers.length > 0
|
||||
|
||||
@ -43,8 +43,13 @@ export async function resolveGatewayRuntimeConfig(params: {
|
||||
const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback";
|
||||
const customBindHost = params.cfg.gateway?.customBindHost;
|
||||
const bindHost = params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost));
|
||||
// webApp.enabled is the primary toggle for all web UIs.
|
||||
// controlUi.enabled is a more specific override if set explicitly.
|
||||
const controlUiEnabled =
|
||||
params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true;
|
||||
params.controlUiEnabled ??
|
||||
params.cfg.gateway?.controlUi?.enabled ??
|
||||
params.cfg.gateway?.webApp?.enabled ??
|
||||
true;
|
||||
const openAiChatCompletionsEnabled =
|
||||
params.openAiChatCompletionsEnabled ??
|
||||
params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ??
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
scheduleRestartSentinelWake,
|
||||
shouldWakeFromRestartSentinel,
|
||||
} from "./server-restart-sentinel.js";
|
||||
import { type WebAppHandle, startWebAppIfEnabled } from "./server-web-app.js";
|
||||
|
||||
export async function startGatewaySidecars(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
@ -37,6 +38,11 @@ export async function startGatewaySidecars(params: {
|
||||
};
|
||||
logChannels: { info: (msg: string) => void; error: (msg: string) => void };
|
||||
logBrowser: { error: (msg: string) => void };
|
||||
logWebApp: {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
};
|
||||
}) {
|
||||
// Start OpenClaw browser control server (unless disabled via config).
|
||||
let browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> = null;
|
||||
@ -46,6 +52,14 @@ export async function startGatewaySidecars(params: {
|
||||
params.logBrowser.error(`server failed to start: ${String(err)}`);
|
||||
}
|
||||
|
||||
// Start the Ironclaw Next.js web app if enabled (gateway.webApp.enabled).
|
||||
let webApp: WebAppHandle | null = null;
|
||||
try {
|
||||
webApp = await startWebAppIfEnabled(params.cfg.gateway?.webApp, params.logWebApp);
|
||||
} catch (err) {
|
||||
params.logWebApp.error(`web app failed to start: ${String(err)}`);
|
||||
}
|
||||
|
||||
// Start Gmail watcher if configured (hooks.gmail.account).
|
||||
if (!isTruthyEnvValue(process.env.OPENCLAW_SKIP_GMAIL_WATCHER)) {
|
||||
try {
|
||||
@ -156,5 +170,5 @@ export async function startGatewaySidecars(params: {
|
||||
}, 750);
|
||||
}
|
||||
|
||||
return { browserControl, pluginServices };
|
||||
return { browserControl, pluginServices, webApp };
|
||||
}
|
||||
|
||||
184
src/gateway/server-web-app.ts
Normal file
184
src/gateway/server-web-app.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { GatewayWebAppConfig } from "../config/types.gateway.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
|
||||
export const DEFAULT_WEB_APP_PORT = 3100;
|
||||
|
||||
export type WebAppHandle = {
|
||||
port: number;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the `apps/web` directory relative to the package root.
|
||||
* Walks up from the current module until we find the workspace root
|
||||
* (identified by the presence of `apps/web/package.json`).
|
||||
*/
|
||||
function resolveWebAppDir(): string | null {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
let dir = path.dirname(__filename);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = path.join(dir, "apps", "web", "package.json");
|
||||
if (fs.existsSync(candidate)) {
|
||||
return path.join(dir, "apps", "web");
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
break;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check whether a Next.js standalone build exists (from `output: "standalone"`). */
|
||||
function hasStandaloneBuild(webAppDir: string): boolean {
|
||||
return fs.existsSync(path.join(webAppDir, ".next", "standalone", "server.js"));
|
||||
}
|
||||
|
||||
/** Check whether a regular Next.js production build exists. */
|
||||
function hasNextBuild(webAppDir: string): boolean {
|
||||
return fs.existsSync(path.join(webAppDir, ".next", "BUILD_ID"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Ironclaw Next.js web app as a child process.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Standalone build (`.next/standalone/server.js`) — shipped in the npm
|
||||
* package; runs with plain `node`, no extra deps needed.
|
||||
* 2. Regular build (`.next/BUILD_ID`) — runs via `npx next start`.
|
||||
* 3. No build — builds from source, then starts.
|
||||
*/
|
||||
export async function startWebAppIfEnabled(
|
||||
cfg: GatewayWebAppConfig | undefined,
|
||||
log: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void },
|
||||
): Promise<WebAppHandle | null> {
|
||||
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_WEB_APP)) {
|
||||
return null;
|
||||
}
|
||||
if (!cfg?.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = cfg.port ?? DEFAULT_WEB_APP_PORT;
|
||||
const devMode = cfg.dev === true; // default false (production)
|
||||
|
||||
const webAppDir = resolveWebAppDir();
|
||||
if (!webAppDir) {
|
||||
log.warn("apps/web directory not found — skipping web app");
|
||||
return null;
|
||||
}
|
||||
|
||||
let child: ChildProcess;
|
||||
|
||||
if (devMode) {
|
||||
// Dev mode: ensure deps, then `next dev`.
|
||||
await ensureDepsInstalled(webAppDir, log);
|
||||
log.info(`starting web app (dev) on port ${port}…`);
|
||||
child = spawn("npx", ["next", "dev", "--port", String(port)], {
|
||||
cwd: webAppDir,
|
||||
stdio: "pipe",
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
});
|
||||
} else if (hasStandaloneBuild(webAppDir)) {
|
||||
// Standalone build: run directly with node — no deps needed.
|
||||
log.info("using pre-built standalone web app");
|
||||
const serverJs = path.join(webAppDir, ".next", "standalone", "server.js");
|
||||
child = spawn(process.execPath, [serverJs], {
|
||||
cwd: path.join(webAppDir, ".next", "standalone"),
|
||||
stdio: "pipe",
|
||||
env: { ...process.env, PORT: String(port), HOSTNAME: "0.0.0.0" },
|
||||
});
|
||||
} else {
|
||||
// No standalone — fall back to regular next start.
|
||||
await ensureDepsInstalled(webAppDir, log);
|
||||
if (!hasNextBuild(webAppDir)) {
|
||||
log.info("building web app for production…");
|
||||
await runCommand("npx", ["next", "build"], webAppDir);
|
||||
} else {
|
||||
log.info("pre-built web app found — skipping build");
|
||||
}
|
||||
log.info(`starting web app (production) on port ${port}…`);
|
||||
child = spawn("npx", ["next", "start", "--port", String(port)], {
|
||||
cwd: webAppDir,
|
||||
stdio: "pipe",
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
});
|
||||
}
|
||||
|
||||
// Forward child stdout/stderr to the gateway log.
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n").filter(Boolean)) {
|
||||
log.info(line);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n").filter(Boolean)) {
|
||||
log.warn(line);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
log.error(`web app process error: ${String(err)}`);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code !== null && code !== 0) {
|
||||
log.warn(`web app exited with code ${code}`);
|
||||
} else if (signal) {
|
||||
log.info(`web app terminated by signal ${signal}`);
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`web app available at http://localhost:${port}`);
|
||||
|
||||
return {
|
||||
port,
|
||||
stop: async () => {
|
||||
if (child.exitCode === null && !child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && !child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
resolve();
|
||||
}, 5_000);
|
||||
child.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function ensureDepsInstalled(
|
||||
webAppDir: string,
|
||||
log: { info: (msg: string) => void },
|
||||
): Promise<void> {
|
||||
const nodeModulesDir = path.join(webAppDir, "node_modules");
|
||||
if (fs.existsSync(nodeModulesDir)) {
|
||||
return;
|
||||
}
|
||||
log.info("installing web app dependencies…");
|
||||
await runCommand("pnpm", ["install"], webAppDir);
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(cmd, args, { cwd, stdio: "pipe", env: { ...process.env } });
|
||||
proc.on("close", (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(`${cmd} ${args[0]} exited with code ${code}`)),
|
||||
);
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
@ -88,6 +88,7 @@ const logDiscovery = log.child("discovery");
|
||||
const logTailscale = log.child("tailscale");
|
||||
const logChannels = log.child("channels");
|
||||
const logBrowser = log.child("browser");
|
||||
const logWebApp = log.child("webapp");
|
||||
const logHealth = log.child("health");
|
||||
const logCron = log.child("cron");
|
||||
const logReload = log.child("reload");
|
||||
@ -217,6 +218,27 @@ export async function startGatewayServer(
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure gateway.webApp is enabled by default for all configs.
|
||||
// Existing configs created before the web app feature won't have this key,
|
||||
// so we backfill it on startup and persist.
|
||||
{
|
||||
const currentCfg = loadConfig();
|
||||
if (currentCfg.gateway?.webApp === undefined) {
|
||||
try {
|
||||
await writeConfigFile({
|
||||
...currentCfg,
|
||||
gateway: {
|
||||
...currentCfg.gateway,
|
||||
webApp: { enabled: true },
|
||||
},
|
||||
});
|
||||
log.info("gateway: auto-enabled webApp (gateway.webApp.enabled = true)");
|
||||
} catch (err) {
|
||||
log.warn(`gateway: failed to persist webApp auto-enable: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cfgAtStart = loadConfig();
|
||||
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
|
||||
if (diagnosticsEnabled) {
|
||||
@ -546,7 +568,8 @@ export async function startGatewayServer(
|
||||
});
|
||||
|
||||
let browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> = null;
|
||||
({ browserControl, pluginServices } = await startGatewaySidecars({
|
||||
let webApp: Awaited<ReturnType<typeof startGatewaySidecars>>["webApp"] = null;
|
||||
({ browserControl, pluginServices, webApp } = await startGatewaySidecars({
|
||||
cfg: cfgAtStart,
|
||||
pluginRegistry,
|
||||
defaultWorkspaceDir,
|
||||
@ -556,6 +579,7 @@ export async function startGatewayServer(
|
||||
logHooks,
|
||||
logChannels,
|
||||
logBrowser,
|
||||
logWebApp,
|
||||
}));
|
||||
|
||||
const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({
|
||||
@ -617,6 +641,7 @@ export async function startGatewayServer(
|
||||
clients,
|
||||
configReloader,
|
||||
browserControl,
|
||||
webApp,
|
||||
wss,
|
||||
httpServer,
|
||||
httpServers,
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
} from "../commands/onboard-helpers.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
||||
import { DEFAULT_WEB_APP_PORT } from "../gateway/server-web-app.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import { restoreTerminalState } from "../terminal/restore.js";
|
||||
import { runTui } from "../tui/tui.js";
|
||||
@ -345,34 +346,23 @@ export async function finalizeOnboardingWizard(
|
||||
});
|
||||
launchedTui = true;
|
||||
} else if (hatchChoice === "web") {
|
||||
const webAppPort = nextConfig.gateway?.webApp?.port ?? DEFAULT_WEB_APP_PORT;
|
||||
const webAppUrl = `http://localhost:${webAppPort}`;
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (browserSupport.ok) {
|
||||
controlUiOpened = await openUrl(authedUrl);
|
||||
if (!controlUiOpened) {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
||||
});
|
||||
controlUiOpened = await openUrl(webAppUrl);
|
||||
}
|
||||
await prompter.note(
|
||||
[
|
||||
`Dashboard link (with token): ${authedUrl}`,
|
||||
`Ironclaw Web UI: ${webAppUrl}`,
|
||||
controlUiOpened
|
||||
? "Opened in your browser. Keep that tab to control Ironclaw."
|
||||
: "Copy/paste this URL in a browser on this machine to control Ironclaw.",
|
||||
controlUiOpenHint,
|
||||
? "Opened in your browser."
|
||||
: "Copy/paste this URL in a browser on this machine.",
|
||||
`Dashboard (control UI): ${authedUrl}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Dashboard ready",
|
||||
"Web UI ready",
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
@ -515,10 +505,10 @@ export async function finalizeOnboardingWizard(
|
||||
|
||||
await prompter.outro(
|
||||
controlUiOpened
|
||||
? "Onboarding complete. Dashboard opened; keep that tab to control Ironclaw."
|
||||
? "Onboarding complete. Web UI opened; keep that tab to control Ironclaw."
|
||||
: seededInBackground
|
||||
? "Onboarding complete. Web UI seeded in the background; open it anytime with the dashboard link above."
|
||||
: "Onboarding complete. Use the dashboard link above to control Ironclaw.",
|
||||
? "Onboarding complete. Web UI seeded in the background; open it anytime with the link above."
|
||||
: "Onboarding complete. Use the links above to control Ironclaw.",
|
||||
);
|
||||
|
||||
return { launchedTui };
|
||||
|
||||
@ -247,6 +247,7 @@ export async function configureGatewayForOnboarding(
|
||||
mode: tailscaleMode as GatewayTailscaleMode,
|
||||
resetOnExit: tailscaleResetOnExit,
|
||||
},
|
||||
webApp: { enabled: true },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user