pilotswarm-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -0
- package/auth/authz/engine.js +139 -0
- package/auth/config.js +110 -0
- package/auth/index.js +153 -0
- package/auth/normalize/entra.js +22 -0
- package/auth/providers/entra.js +76 -0
- package/auth/providers/none.js +24 -0
- package/auth.js +10 -0
- package/bin/serve.js +53 -0
- package/config.js +20 -0
- package/dist/app.js +469 -0
- package/dist/assets/index-BSVg-lGb.css +1 -0
- package/dist/assets/index-BXD5YP7A.js +24 -0
- package/dist/assets/msal-CytV9RFv.js +7 -0
- package/dist/assets/pilotswarm-WX3NED6m.js +40 -0
- package/dist/assets/react-jg0oazEi.js +1 -0
- package/dist/index.html +16 -0
- package/node_modules/pilotswarm-ui-core/README.md +6 -0
- package/node_modules/pilotswarm-ui-core/package.json +32 -0
- package/node_modules/pilotswarm-ui-core/src/commands.js +72 -0
- package/node_modules/pilotswarm-ui-core/src/context-usage.js +212 -0
- package/node_modules/pilotswarm-ui-core/src/controller.js +3613 -0
- package/node_modules/pilotswarm-ui-core/src/formatting.js +872 -0
- package/node_modules/pilotswarm-ui-core/src/history.js +571 -0
- package/node_modules/pilotswarm-ui-core/src/index.js +13 -0
- package/node_modules/pilotswarm-ui-core/src/layout.js +196 -0
- package/node_modules/pilotswarm-ui-core/src/reducer.js +1027 -0
- package/node_modules/pilotswarm-ui-core/src/selectors.js +2786 -0
- package/node_modules/pilotswarm-ui-core/src/session-tree.js +109 -0
- package/node_modules/pilotswarm-ui-core/src/state.js +80 -0
- package/node_modules/pilotswarm-ui-core/src/store.js +23 -0
- package/node_modules/pilotswarm-ui-core/src/system-titles.js +24 -0
- package/node_modules/pilotswarm-ui-core/src/themes/catppuccin-mocha.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/cobalt2.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/dark-high-contrast.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/dracula.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/github-dark.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/gruvbox-dark.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/hacker-x-matrix.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/hacker-x-orion-prime.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/helpers.js +77 -0
- package/node_modules/pilotswarm-ui-core/src/themes/index.js +42 -0
- package/node_modules/pilotswarm-ui-core/src/themes/noctis-viola.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/noctis.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/nord.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/solarized-dark.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/tokyo-night.js +56 -0
- package/node_modules/pilotswarm-ui-react/README.md +5 -0
- package/node_modules/pilotswarm-ui-react/package.json +36 -0
- package/node_modules/pilotswarm-ui-react/src/components.js +1316 -0
- package/node_modules/pilotswarm-ui-react/src/index.js +4 -0
- package/node_modules/pilotswarm-ui-react/src/platform.js +15 -0
- package/node_modules/pilotswarm-ui-react/src/use-controller-state.js +38 -0
- package/node_modules/pilotswarm-ui-react/src/web-app.js +2661 -0
- package/package.json +64 -0
- package/runtime.js +146 -0
- package/server.js +311 -0
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pilotswarm-web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Browser-native web portal for PilotSwarm with shared controller/state/theme reuse.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pilotswarm-web": "./bin/serve.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "./server.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./server.js"
|
|
13
|
+
},
|
|
14
|
+
"bundledDependencies": [
|
|
15
|
+
"pilotswarm-ui-core",
|
|
16
|
+
"pilotswarm-ui-react"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
"auth.js",
|
|
20
|
+
"auth/**/*",
|
|
21
|
+
"bin/**/*",
|
|
22
|
+
"config.js",
|
|
23
|
+
"dist/**/*",
|
|
24
|
+
"README.md",
|
|
25
|
+
"runtime.js",
|
|
26
|
+
"server.js"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "vite build",
|
|
30
|
+
"prepack": "npm run build && node -e \"const fs=require('fs');const cp=(s,d)=>{fs.mkdirSync(d,{recursive:true});for(const e of fs.readdirSync(s,{withFileTypes:true})){const sp=s+'/'+e.name,dp=d+'/'+e.name;e.isDirectory()?cp(sp,dp):fs.copyFileSync(sp,dp)}};for(const p of ['pilotswarm-ui-core','pilotswarm-ui-react']){const src='../'+p.replace('pilotswarm-','');const dst='node_modules/'+p;fs.rmSync(dst,{recursive:true,force:true});fs.mkdirSync(dst,{recursive:true});fs.copyFileSync(src+'/package.json',dst+'/package.json');fs.copyFileSync(src+'/README.md',dst+'/README.md');cp(src+'/src',dst+'/src')}\"",
|
|
31
|
+
"start": "node server.js",
|
|
32
|
+
"dev": "vite --host 0.0.0.0",
|
|
33
|
+
"dev:server": "node server.js",
|
|
34
|
+
"clean": "rm -rf dist"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@azure/msal-browser": "^4.26.1",
|
|
38
|
+
"express": "^5.1.0",
|
|
39
|
+
"jose": "^6.2.2",
|
|
40
|
+
"pilotswarm-cli": "^0.1.15",
|
|
41
|
+
"pilotswarm-ui-core": "0.1.0",
|
|
42
|
+
"pilotswarm-ui-react": "0.1.0",
|
|
43
|
+
"react": "^19.2.4",
|
|
44
|
+
"react-dom": "^19.2.4",
|
|
45
|
+
"ws": "^8.18.2"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@vitejs/plugin-react": "^5.1.0",
|
|
49
|
+
"vite": "^7.2.0"
|
|
50
|
+
},
|
|
51
|
+
"author": "Affan Dar",
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "https://github.com/affandar/PilotSwarm"
|
|
56
|
+
},
|
|
57
|
+
"homepage": "https://github.com/affandar/PilotSwarm",
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/affandar/PilotSwarm/issues"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=24.0.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/runtime.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { NodeSdkTransport } from "pilotswarm-cli/portal";
|
|
2
|
+
|
|
3
|
+
function normalizeParams(params) {
|
|
4
|
+
return params && typeof params === "object" ? params : {};
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class PortalRuntime {
|
|
8
|
+
constructor({ store, mode }) {
|
|
9
|
+
this.transport = new NodeSdkTransport({ store, mode });
|
|
10
|
+
this.mode = mode;
|
|
11
|
+
this.started = false;
|
|
12
|
+
this.startPromise = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async start() {
|
|
16
|
+
if (this.started) return;
|
|
17
|
+
if (!this.startPromise) {
|
|
18
|
+
this.startPromise = this.transport.start()
|
|
19
|
+
.then(() => {
|
|
20
|
+
this.started = true;
|
|
21
|
+
})
|
|
22
|
+
.finally(() => {
|
|
23
|
+
this.startPromise = null;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
await this.startPromise;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async stop() {
|
|
30
|
+
if (!this.started && !this.startPromise) return;
|
|
31
|
+
if (this.startPromise) {
|
|
32
|
+
await this.startPromise.catch(() => {});
|
|
33
|
+
}
|
|
34
|
+
if (this.started) {
|
|
35
|
+
await this.transport.stop();
|
|
36
|
+
this.started = false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async getBootstrap() {
|
|
41
|
+
await this.start();
|
|
42
|
+
return {
|
|
43
|
+
mode: this.mode,
|
|
44
|
+
workerCount: typeof this.transport.getWorkerCount === "function"
|
|
45
|
+
? this.transport.getWorkerCount()
|
|
46
|
+
: null,
|
|
47
|
+
logConfig: typeof this.transport.getLogConfig === "function"
|
|
48
|
+
? this.transport.getLogConfig()
|
|
49
|
+
: null,
|
|
50
|
+
defaultModel: typeof this.transport.getDefaultModel === "function"
|
|
51
|
+
? this.transport.getDefaultModel()
|
|
52
|
+
: null,
|
|
53
|
+
modelsByProvider: typeof this.transport.getModelsByProvider === "function"
|
|
54
|
+
? this.transport.getModelsByProvider()
|
|
55
|
+
: [],
|
|
56
|
+
creatableAgents: typeof this.transport.listCreatableAgents === "function"
|
|
57
|
+
? await this.transport.listCreatableAgents()
|
|
58
|
+
: [],
|
|
59
|
+
sessionCreationPolicy: typeof this.transport.getSessionCreationPolicy === "function"
|
|
60
|
+
? this.transport.getSessionCreationPolicy()
|
|
61
|
+
: null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async call(method, params = {}) {
|
|
66
|
+
await this.start();
|
|
67
|
+
const safeParams = normalizeParams(params);
|
|
68
|
+
switch (method) {
|
|
69
|
+
case "listSessions":
|
|
70
|
+
return this.transport.listSessions();
|
|
71
|
+
case "getSession":
|
|
72
|
+
return this.transport.getSession(safeParams.sessionId);
|
|
73
|
+
case "getOrchestrationStats":
|
|
74
|
+
return this.transport.getOrchestrationStats(safeParams.sessionId);
|
|
75
|
+
case "getExecutionHistory":
|
|
76
|
+
return this.transport.getExecutionHistory(safeParams.sessionId, safeParams.executionId);
|
|
77
|
+
case "createSession":
|
|
78
|
+
return this.transport.createSession({ model: safeParams.model });
|
|
79
|
+
case "createSessionForAgent":
|
|
80
|
+
return this.transport.createSessionForAgent(safeParams.agentName, {
|
|
81
|
+
model: safeParams.model,
|
|
82
|
+
title: safeParams.title,
|
|
83
|
+
splash: safeParams.splash,
|
|
84
|
+
initialPrompt: safeParams.initialPrompt,
|
|
85
|
+
});
|
|
86
|
+
case "listCreatableAgents":
|
|
87
|
+
return this.transport.listCreatableAgents();
|
|
88
|
+
case "getSessionCreationPolicy":
|
|
89
|
+
return this.transport.getSessionCreationPolicy();
|
|
90
|
+
case "sendMessage":
|
|
91
|
+
return this.transport.sendMessage(safeParams.sessionId, safeParams.prompt, safeParams.options);
|
|
92
|
+
case "sendAnswer":
|
|
93
|
+
return this.transport.sendAnswer(safeParams.sessionId, safeParams.answer);
|
|
94
|
+
case "renameSession":
|
|
95
|
+
return this.transport.renameSession(safeParams.sessionId, safeParams.title);
|
|
96
|
+
case "cancelSession":
|
|
97
|
+
return this.transport.cancelSession(safeParams.sessionId);
|
|
98
|
+
case "completeSession":
|
|
99
|
+
return this.transport.completeSession(safeParams.sessionId, safeParams.reason);
|
|
100
|
+
case "deleteSession":
|
|
101
|
+
return this.transport.deleteSession(safeParams.sessionId);
|
|
102
|
+
case "listModels":
|
|
103
|
+
return this.transport.listModels();
|
|
104
|
+
case "listArtifacts":
|
|
105
|
+
return this.transport.listArtifacts(safeParams.sessionId);
|
|
106
|
+
case "downloadArtifact":
|
|
107
|
+
return this.transport.downloadArtifact(safeParams.sessionId, safeParams.filename);
|
|
108
|
+
case "uploadArtifact":
|
|
109
|
+
return this.transport.uploadArtifactContent(
|
|
110
|
+
safeParams.sessionId,
|
|
111
|
+
safeParams.filename,
|
|
112
|
+
safeParams.content,
|
|
113
|
+
safeParams.contentType,
|
|
114
|
+
);
|
|
115
|
+
case "exportExecutionHistory":
|
|
116
|
+
return this.transport.exportExecutionHistory(safeParams.sessionId);
|
|
117
|
+
case "getModelsByProvider":
|
|
118
|
+
return this.transport.getModelsByProvider();
|
|
119
|
+
case "getDefaultModel":
|
|
120
|
+
return this.transport.getDefaultModel();
|
|
121
|
+
case "getSessionEvents":
|
|
122
|
+
return this.transport.getSessionEvents(safeParams.sessionId, safeParams.afterSeq, safeParams.limit);
|
|
123
|
+
case "getSessionEventsBefore":
|
|
124
|
+
return this.transport.getSessionEventsBefore(safeParams.sessionId, safeParams.beforeSeq, safeParams.limit);
|
|
125
|
+
case "getLogConfig":
|
|
126
|
+
return this.transport.getLogConfig();
|
|
127
|
+
case "getWorkerCount":
|
|
128
|
+
return this.transport.getWorkerCount();
|
|
129
|
+
default:
|
|
130
|
+
throw new Error(`Unsupported portal RPC method: ${method}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async downloadArtifact(sessionId, filename) {
|
|
135
|
+
await this.start();
|
|
136
|
+
return this.transport.downloadArtifact(sessionId, filename);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
subscribeSession(sessionId, handler) {
|
|
140
|
+
return this.transport.subscribeSession(sessionId, handler);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
startLogTail(handler) {
|
|
144
|
+
return this.transport.startLogTail(handler);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { WebSocketServer } from "ws";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import https from "node:https";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { getPortalAssetFile, getPortalConfig } from "./config.js";
|
|
9
|
+
import { authenticateRequest, extractToken, getAuthConfig, authenticateToken } from "./auth.js";
|
|
10
|
+
import { getPublicAuthContext } from "./auth/authz/engine.js";
|
|
11
|
+
import { PortalRuntime } from "./runtime.js";
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const DIST_DIR = path.join(__dirname, "dist");
|
|
15
|
+
|
|
16
|
+
function getPortalMode() {
|
|
17
|
+
const explicitMode = process.env.PORTAL_TUI_MODE || process.env.PORTAL_MODE;
|
|
18
|
+
if (explicitMode) return explicitMode;
|
|
19
|
+
return process.env.KUBERNETES_SERVICE_HOST ? "remote" : "local";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createPortalServer({ app }) {
|
|
23
|
+
const certPath = process.env.TLS_CERT_PATH;
|
|
24
|
+
const keyPath = process.env.TLS_KEY_PATH;
|
|
25
|
+
if (certPath && keyPath && fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|
26
|
+
return {
|
|
27
|
+
protocol: "https",
|
|
28
|
+
server: https.createServer({
|
|
29
|
+
cert: fs.readFileSync(certPath),
|
|
30
|
+
key: fs.readFileSync(keyPath),
|
|
31
|
+
}, app),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
protocol: "http",
|
|
36
|
+
server: http.createServer(app),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isSafeThemeId(value) {
|
|
41
|
+
return /^[\w-]+$/u.test(String(value || ""));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createJsonRpcError(error, status = 500) {
|
|
45
|
+
return {
|
|
46
|
+
status,
|
|
47
|
+
body: {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: error?.message || String(error),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function startServer(opts = {}) {
|
|
55
|
+
const { port = Number(process.env.PORT) || 3001 } = opts;
|
|
56
|
+
const portalConfig = getPortalConfig();
|
|
57
|
+
const mode = getPortalMode();
|
|
58
|
+
const runtime = new PortalRuntime({
|
|
59
|
+
store: process.env.DATABASE_URL || "sqlite::memory:",
|
|
60
|
+
mode,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const app = express();
|
|
64
|
+
app.set("trust proxy", true);
|
|
65
|
+
app.use(express.json({ limit: "2mb" }));
|
|
66
|
+
|
|
67
|
+
const { server, protocol } = createPortalServer({ app });
|
|
68
|
+
|
|
69
|
+
async function requireAuth(req, res, next) {
|
|
70
|
+
const auth = await authenticateRequest(req);
|
|
71
|
+
if (!auth.ok) {
|
|
72
|
+
res.status(auth.status).json({ ok: false, error: auth.error || (auth.status === 403 ? "Forbidden" : "Unauthorized") });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
req.auth = auth;
|
|
76
|
+
req.authClaims = auth.principal?.rawClaims || null;
|
|
77
|
+
next();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
app.get("/api/health", async (_req, res) => {
|
|
81
|
+
const started = runtime.started;
|
|
82
|
+
res.json({
|
|
83
|
+
ok: true,
|
|
84
|
+
started,
|
|
85
|
+
mode,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
app.get("/api/portal-config", async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const auth = await getAuthConfig(req);
|
|
92
|
+
res.json({
|
|
93
|
+
ok: true,
|
|
94
|
+
portal: portalConfig,
|
|
95
|
+
auth,
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
const payload = createJsonRpcError(error, 500);
|
|
99
|
+
res.status(payload.status).json(payload.body);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
app.get("/api/auth-config", async (req, res) => {
|
|
104
|
+
try {
|
|
105
|
+
const auth = await getAuthConfig(req);
|
|
106
|
+
res.json(auth);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const payload = createJsonRpcError(error, 500);
|
|
109
|
+
res.status(payload.status).json(payload.body);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
app.get("/api/auth/me", requireAuth, async (req, res) => {
|
|
114
|
+
res.json({
|
|
115
|
+
ok: true,
|
|
116
|
+
...getPublicAuthContext(req.auth),
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
app.get("/api/bootstrap", requireAuth, async (_req, res) => {
|
|
121
|
+
try {
|
|
122
|
+
const bootstrap = await runtime.getBootstrap();
|
|
123
|
+
res.json({
|
|
124
|
+
ok: true,
|
|
125
|
+
...bootstrap,
|
|
126
|
+
auth: getPublicAuthContext(_req.auth),
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const payload = createJsonRpcError(error, 500);
|
|
130
|
+
res.status(payload.status).json(payload.body);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
app.post("/api/rpc", requireAuth, async (req, res) => {
|
|
135
|
+
const method = String(req.body?.method || "").trim();
|
|
136
|
+
if (!method) {
|
|
137
|
+
res.status(400).json({ ok: false, error: "RPC method is required" });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const result = await runtime.call(method, req.body?.params || {});
|
|
142
|
+
res.json({ ok: true, result });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const status = /Unsupported portal RPC method/i.test(String(error?.message || ""))
|
|
145
|
+
? 400
|
|
146
|
+
: 500;
|
|
147
|
+
const payload = createJsonRpcError(error, status);
|
|
148
|
+
res.status(payload.status).json(payload.body);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
app.get("/api/sessions/:sessionId/artifacts/:filename/download", requireAuth, async (req, res) => {
|
|
153
|
+
try {
|
|
154
|
+
const sessionId = req.params.sessionId;
|
|
155
|
+
const filename = req.params.filename;
|
|
156
|
+
const content = await runtime.downloadArtifact(sessionId, filename);
|
|
157
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
158
|
+
res.setHeader("content-disposition", `attachment; filename="${path.basename(filename)}"`);
|
|
159
|
+
res.send(content);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const payload = createJsonRpcError(error, 404);
|
|
162
|
+
res.status(payload.status).json(payload.body);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
app.get("/api/portal-assets/:assetName", async (req, res) => {
|
|
167
|
+
const assetFile = getPortalAssetFile(req.params.assetName);
|
|
168
|
+
if (!assetFile || !fs.existsSync(assetFile)) {
|
|
169
|
+
res.status(404).end();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
res.sendFile(assetFile, {
|
|
173
|
+
maxAge: "1h",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (fs.existsSync(DIST_DIR)) {
|
|
178
|
+
app.use(express.static(DIST_DIR));
|
|
179
|
+
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
|
180
|
+
res.sendFile(path.join(DIST_DIR, "index.html"));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const wss = new WebSocketServer({ server, path: "/portal-ws" });
|
|
185
|
+
wss.on("connection", async (ws, req) => {
|
|
186
|
+
const auth = await authenticateToken(extractToken(req), req);
|
|
187
|
+
if (!auth.ok) {
|
|
188
|
+
ws.close(auth.status === 403 ? 4403 : 4401, auth.error || (auth.status === 403 ? "Forbidden" : "Unauthorized"));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const sessionSubscriptions = new Map();
|
|
193
|
+
let logUnsubscribe = null;
|
|
194
|
+
|
|
195
|
+
const send = (message) => {
|
|
196
|
+
if (ws.readyState === ws.OPEN) {
|
|
197
|
+
ws.send(JSON.stringify(message));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
send({ type: "ready" });
|
|
202
|
+
|
|
203
|
+
ws.on("message", async (raw) => {
|
|
204
|
+
let message;
|
|
205
|
+
try {
|
|
206
|
+
message = JSON.parse(String(raw));
|
|
207
|
+
} catch {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const type = String(message?.type || "");
|
|
212
|
+
if (type === "subscribeSession") {
|
|
213
|
+
const sessionId = String(message?.sessionId || "").trim();
|
|
214
|
+
if (!sessionId || sessionSubscriptions.has(sessionId)) return;
|
|
215
|
+
try {
|
|
216
|
+
await runtime.start();
|
|
217
|
+
const unsubscribe = runtime.subscribeSession(sessionId, (event) => {
|
|
218
|
+
send({ type: "sessionEvent", sessionId, event });
|
|
219
|
+
});
|
|
220
|
+
sessionSubscriptions.set(sessionId, unsubscribe);
|
|
221
|
+
send({ type: "subscribedSession", sessionId });
|
|
222
|
+
} catch (error) {
|
|
223
|
+
send({ type: "error", scope: "session", sessionId, error: error?.message || String(error) });
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (type === "unsubscribeSession") {
|
|
229
|
+
const sessionId = String(message?.sessionId || "").trim();
|
|
230
|
+
const unsubscribe = sessionSubscriptions.get(sessionId);
|
|
231
|
+
if (unsubscribe) {
|
|
232
|
+
unsubscribe();
|
|
233
|
+
sessionSubscriptions.delete(sessionId);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (type === "subscribeLogs") {
|
|
239
|
+
if (logUnsubscribe) return;
|
|
240
|
+
try {
|
|
241
|
+
await runtime.start();
|
|
242
|
+
logUnsubscribe = runtime.startLogTail((entry) => {
|
|
243
|
+
send({ type: "logEntry", entry });
|
|
244
|
+
});
|
|
245
|
+
send({ type: "subscribedLogs" });
|
|
246
|
+
} catch (error) {
|
|
247
|
+
send({ type: "error", scope: "logs", error: error?.message || String(error) });
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (type === "unsubscribeLogs") {
|
|
253
|
+
if (logUnsubscribe) {
|
|
254
|
+
logUnsubscribe();
|
|
255
|
+
logUnsubscribe = null;
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (type === "theme" && isSafeThemeId(message?.themeId)) {
|
|
261
|
+
send({ type: "themeAck", themeId: message.themeId });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
ws.on("close", () => {
|
|
266
|
+
for (const unsubscribe of sessionSubscriptions.values()) {
|
|
267
|
+
try {
|
|
268
|
+
unsubscribe();
|
|
269
|
+
} catch {}
|
|
270
|
+
}
|
|
271
|
+
sessionSubscriptions.clear();
|
|
272
|
+
if (logUnsubscribe) {
|
|
273
|
+
try {
|
|
274
|
+
logUnsubscribe();
|
|
275
|
+
} catch {}
|
|
276
|
+
logUnsubscribe = null;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
async function shutdown() {
|
|
282
|
+
for (const client of wss.clients) {
|
|
283
|
+
try {
|
|
284
|
+
client.close();
|
|
285
|
+
} catch {}
|
|
286
|
+
}
|
|
287
|
+
await runtime.stop().catch(() => {});
|
|
288
|
+
server.close();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
process.on("SIGINT", shutdown);
|
|
292
|
+
process.on("SIGTERM", shutdown);
|
|
293
|
+
|
|
294
|
+
await new Promise((resolve, reject) => {
|
|
295
|
+
server.once("error", reject);
|
|
296
|
+
server.listen(port, () => {
|
|
297
|
+
server.off("error", reject);
|
|
298
|
+
resolve();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
console.log(`[portal] PilotSwarm Web at ${protocol}://localhost:${port}`);
|
|
302
|
+
|
|
303
|
+
return server;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (process.argv[1]?.endsWith("server.js") || import.meta.url === `file://${process.argv[1]}`) {
|
|
307
|
+
startServer().catch((error) => {
|
|
308
|
+
console.error("[portal] Failed to start:", error);
|
|
309
|
+
process.exitCode = 1;
|
|
310
|
+
});
|
|
311
|
+
}
|