@shykaruu/jarvis-brain 0.4.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/LICENSE +153 -0
- package/README.md +428 -0
- package/bin/jarvis.ts +449 -0
- package/package.json +79 -0
- package/roles/activity-observer.yaml +60 -0
- package/roles/ceo-founder.yaml +144 -0
- package/roles/chief-of-staff.yaml +158 -0
- package/roles/dev-lead.yaml +182 -0
- package/roles/executive-assistant.yaml +77 -0
- package/roles/marketing-director.yaml +168 -0
- package/roles/personal-assistant.yaml +266 -0
- package/roles/research-specialist.yaml +60 -0
- package/roles/specialists/content-writer.yaml +53 -0
- package/roles/specialists/customer-support.yaml +57 -0
- package/roles/specialists/data-analyst.yaml +57 -0
- package/roles/specialists/financial-analyst.yaml +56 -0
- package/roles/specialists/hr-specialist.yaml +55 -0
- package/roles/specialists/legal-advisor.yaml +58 -0
- package/roles/specialists/marketing-strategist.yaml +56 -0
- package/roles/specialists/project-coordinator.yaml +55 -0
- package/roles/specialists/research-analyst.yaml +58 -0
- package/roles/specialists/software-engineer.yaml +57 -0
- package/roles/specialists/system-administrator.yaml +57 -0
- package/roles/system-admin.yaml +76 -0
- package/scripts/ensure-bun.cjs +16 -0
- package/src/actions/README.md +421 -0
- package/src/actions/app-control/desktop-controller.test.ts +26 -0
- package/src/actions/app-control/desktop-controller.ts +438 -0
- package/src/actions/app-control/interface.ts +64 -0
- package/src/actions/app-control/linux.ts +273 -0
- package/src/actions/app-control/macos.ts +54 -0
- package/src/actions/app-control/sidecar-launcher.test.ts +23 -0
- package/src/actions/app-control/sidecar-launcher.ts +286 -0
- package/src/actions/app-control/windows.ts +44 -0
- package/src/actions/browser/cdp.ts +138 -0
- package/src/actions/browser/chrome-launcher.ts +261 -0
- package/src/actions/browser/session.ts +506 -0
- package/src/actions/browser/stealth.ts +49 -0
- package/src/actions/index.ts +20 -0
- package/src/actions/terminal/executor.ts +157 -0
- package/src/actions/terminal/wsl-bridge.ts +126 -0
- package/src/actions/test.ts +93 -0
- package/src/actions/tools/agents.ts +363 -0
- package/src/actions/tools/builtin.ts +950 -0
- package/src/actions/tools/commitments.ts +192 -0
- package/src/actions/tools/content.ts +217 -0
- package/src/actions/tools/delegate.ts +147 -0
- package/src/actions/tools/desktop.test.ts +55 -0
- package/src/actions/tools/desktop.ts +305 -0
- package/src/actions/tools/documents.ts +169 -0
- package/src/actions/tools/goals.ts +376 -0
- package/src/actions/tools/local-tools-guard.ts +31 -0
- package/src/actions/tools/registry.ts +173 -0
- package/src/actions/tools/research.ts +111 -0
- package/src/actions/tools/sidecar-list.ts +57 -0
- package/src/actions/tools/sidecar-route.ts +105 -0
- package/src/actions/tools/workflows.ts +216 -0
- package/src/agents/agent.ts +132 -0
- package/src/agents/delegation.ts +107 -0
- package/src/agents/hierarchy.ts +113 -0
- package/src/agents/index.ts +19 -0
- package/src/agents/messaging.ts +125 -0
- package/src/agents/orchestrator.ts +592 -0
- package/src/agents/role-discovery.ts +61 -0
- package/src/agents/sub-agent-runner.ts +309 -0
- package/src/agents/task-manager.ts +151 -0
- package/src/authority/approval-delivery.ts +59 -0
- package/src/authority/approval.ts +196 -0
- package/src/authority/audit.ts +158 -0
- package/src/authority/authority.test.ts +519 -0
- package/src/authority/deferred-executor.ts +103 -0
- package/src/authority/emergency.ts +66 -0
- package/src/authority/engine.ts +301 -0
- package/src/authority/index.ts +12 -0
- package/src/authority/learning.ts +111 -0
- package/src/authority/tool-action-map.ts +74 -0
- package/src/awareness/analytics.ts +466 -0
- package/src/awareness/awareness.test.ts +332 -0
- package/src/awareness/capture-engine.ts +305 -0
- package/src/awareness/context-graph.ts +130 -0
- package/src/awareness/context-tracker.ts +349 -0
- package/src/awareness/index.ts +25 -0
- package/src/awareness/intelligence.ts +321 -0
- package/src/awareness/ocr-engine.ts +88 -0
- package/src/awareness/service.ts +528 -0
- package/src/awareness/struggle-detector.ts +342 -0
- package/src/awareness/suggestion-engine.ts +476 -0
- package/src/awareness/types.ts +201 -0
- package/src/cli/autostart.ts +417 -0
- package/src/cli/deps.ts +449 -0
- package/src/cli/doctor.ts +238 -0
- package/src/cli/helpers.ts +401 -0
- package/src/cli/onboard.ts +827 -0
- package/src/cli/uninstall.test.ts +37 -0
- package/src/cli/uninstall.ts +202 -0
- package/src/comms/README.md +329 -0
- package/src/comms/auth-error.html +48 -0
- package/src/comms/channels/discord.ts +228 -0
- package/src/comms/channels/signal.ts +56 -0
- package/src/comms/channels/telegram.ts +316 -0
- package/src/comms/channels/whatsapp.ts +60 -0
- package/src/comms/channels.test.ts +173 -0
- package/src/comms/dashboard-auth.ts +75 -0
- package/src/comms/desktop-notify.ts +114 -0
- package/src/comms/example.ts +129 -0
- package/src/comms/index.ts +129 -0
- package/src/comms/streaming.ts +149 -0
- package/src/comms/voice.test.ts +504 -0
- package/src/comms/voice.ts +341 -0
- package/src/comms/websocket.test.ts +409 -0
- package/src/comms/websocket.ts +669 -0
- package/src/config/README.md +389 -0
- package/src/config/index.ts +6 -0
- package/src/config/loader.test.ts +183 -0
- package/src/config/loader.ts +148 -0
- package/src/config/types.ts +293 -0
- package/src/daemon/README.md +232 -0
- package/src/daemon/agent-service-interface.ts +9 -0
- package/src/daemon/agent-service.ts +667 -0
- package/src/daemon/api-routes.ts +3067 -0
- package/src/daemon/background-agent-service.ts +396 -0
- package/src/daemon/background-agent.test.ts +78 -0
- package/src/daemon/channel-service.ts +201 -0
- package/src/daemon/commitment-executor.ts +297 -0
- package/src/daemon/dashboard-auth.test.ts +170 -0
- package/src/daemon/event-classifier.ts +239 -0
- package/src/daemon/event-coalescer.ts +123 -0
- package/src/daemon/event-reactor.ts +214 -0
- package/src/daemon/flock.c +7 -0
- package/src/daemon/health.ts +220 -0
- package/src/daemon/index.ts +1070 -0
- package/src/daemon/llm-settings.test.ts +78 -0
- package/src/daemon/llm-settings.ts +450 -0
- package/src/daemon/observer-service.ts +150 -0
- package/src/daemon/pid.test.ts +283 -0
- package/src/daemon/pid.ts +224 -0
- package/src/daemon/research-queue.ts +155 -0
- package/src/daemon/services.ts +175 -0
- package/src/daemon/ws-service.ts +926 -0
- package/src/global.d.ts +4 -0
- package/src/goals/accountability.ts +240 -0
- package/src/goals/awareness-bridge.ts +185 -0
- package/src/goals/estimator.ts +185 -0
- package/src/goals/events.ts +28 -0
- package/src/goals/goals.test.ts +400 -0
- package/src/goals/integration.test.ts +329 -0
- package/src/goals/nl-builder.test.ts +220 -0
- package/src/goals/nl-builder.ts +256 -0
- package/src/goals/rhythm.test.ts +177 -0
- package/src/goals/rhythm.ts +275 -0
- package/src/goals/service.test.ts +135 -0
- package/src/goals/service.ts +407 -0
- package/src/goals/types.ts +106 -0
- package/src/goals/workflow-bridge.ts +96 -0
- package/src/integrations/google-api.ts +134 -0
- package/src/integrations/google-auth.ts +175 -0
- package/src/llm/README.md +291 -0
- package/src/llm/anthropic.ts +400 -0
- package/src/llm/gemini.ts +380 -0
- package/src/llm/groq.ts +406 -0
- package/src/llm/history.ts +147 -0
- package/src/llm/index.ts +21 -0
- package/src/llm/manager.ts +226 -0
- package/src/llm/ollama.ts +316 -0
- package/src/llm/openai.ts +411 -0
- package/src/llm/openrouter.ts +390 -0
- package/src/llm/provider.test.ts +487 -0
- package/src/llm/provider.ts +61 -0
- package/src/llm/test.ts +88 -0
- package/src/observers/README.md +278 -0
- package/src/observers/calendar.ts +113 -0
- package/src/observers/clipboard.ts +136 -0
- package/src/observers/email.ts +109 -0
- package/src/observers/example.ts +58 -0
- package/src/observers/file-watcher.ts +124 -0
- package/src/observers/index.ts +159 -0
- package/src/observers/notifications.ts +197 -0
- package/src/observers/observers.test.ts +203 -0
- package/src/observers/processes.ts +225 -0
- package/src/personality/README.md +61 -0
- package/src/personality/adapter.ts +196 -0
- package/src/personality/index.ts +20 -0
- package/src/personality/learner.ts +209 -0
- package/src/personality/model.ts +132 -0
- package/src/personality/personality.test.ts +236 -0
- package/src/roles/README.md +252 -0
- package/src/roles/authority.ts +120 -0
- package/src/roles/example-usage.ts +198 -0
- package/src/roles/index.ts +42 -0
- package/src/roles/loader.ts +143 -0
- package/src/roles/prompt-builder.ts +218 -0
- package/src/roles/test-multi.ts +102 -0
- package/src/roles/test-role.yaml +77 -0
- package/src/roles/test-utils.ts +93 -0
- package/src/roles/test.ts +106 -0
- package/src/roles/tool-guide.ts +195 -0
- package/src/roles/types.ts +36 -0
- package/src/roles/utils.ts +200 -0
- package/src/scripts/google-setup.ts +168 -0
- package/src/sidecar/connection.ts +179 -0
- package/src/sidecar/index.ts +6 -0
- package/src/sidecar/manager.ts +542 -0
- package/src/sidecar/protocol.ts +85 -0
- package/src/sidecar/rpc.ts +161 -0
- package/src/sidecar/scheduler.ts +136 -0
- package/src/sidecar/types.ts +112 -0
- package/src/sidecar/validator.ts +144 -0
- package/src/sites/builder-tools.ts +215 -0
- package/src/sites/dev-server-manager.ts +286 -0
- package/src/sites/fixtures/security-test-site/.jarvis-project.json +6 -0
- package/src/sites/fixtures/security-test-site/Makefile +15 -0
- package/src/sites/fixtures/security-test-site/README.md +18 -0
- package/src/sites/fixtures/security-test-site/index.html +12 -0
- package/src/sites/fixtures/security-test-site/index.ts +16 -0
- package/src/sites/fixtures/security-test-site/package.json +13 -0
- package/src/sites/fixtures/security-test-site/src/app.tsx +780 -0
- package/src/sites/fixtures/security-test-site/tsconfig.json +10 -0
- package/src/sites/git-manager.ts +240 -0
- package/src/sites/github-manager.ts +355 -0
- package/src/sites/index.ts +25 -0
- package/src/sites/project-manager.ts +389 -0
- package/src/sites/proxy.ts +133 -0
- package/src/sites/service.ts +136 -0
- package/src/sites/templates.ts +169 -0
- package/src/sites/types.ts +89 -0
- package/src/user/profile-followup.test.ts +84 -0
- package/src/user/profile-followup.ts +185 -0
- package/src/user/profile.ts +224 -0
- package/src/vault/README.md +110 -0
- package/src/vault/awareness.ts +341 -0
- package/src/vault/commitments.ts +299 -0
- package/src/vault/content-pipeline.ts +270 -0
- package/src/vault/conversations.ts +173 -0
- package/src/vault/dashboard-sessions.ts +44 -0
- package/src/vault/documents.ts +130 -0
- package/src/vault/entities.ts +185 -0
- package/src/vault/extractor.test.ts +356 -0
- package/src/vault/extractor.ts +345 -0
- package/src/vault/facts.ts +190 -0
- package/src/vault/goals.ts +477 -0
- package/src/vault/index.ts +87 -0
- package/src/vault/keychain.ts +99 -0
- package/src/vault/observations.ts +115 -0
- package/src/vault/relationships.ts +178 -0
- package/src/vault/retrieval.test.ts +139 -0
- package/src/vault/retrieval.ts +258 -0
- package/src/vault/schema.ts +709 -0
- package/src/vault/settings.ts +38 -0
- package/src/vault/user-profile.test.ts +113 -0
- package/src/vault/user-profile.ts +176 -0
- package/src/vault/vectors.ts +92 -0
- package/src/vault/webapp-template-seeds.ts +116 -0
- package/src/vault/webapp-templates.ts +244 -0
- package/src/vault/workflows.ts +403 -0
- package/src/workflows/auto-suggest.ts +290 -0
- package/src/workflows/engine.ts +366 -0
- package/src/workflows/events.ts +24 -0
- package/src/workflows/executor.ts +207 -0
- package/src/workflows/nl-builder.ts +198 -0
- package/src/workflows/nodes/actions/agent-task.ts +73 -0
- package/src/workflows/nodes/actions/calendar-action.ts +85 -0
- package/src/workflows/nodes/actions/code-execution.ts +73 -0
- package/src/workflows/nodes/actions/discord.ts +77 -0
- package/src/workflows/nodes/actions/file-write.ts +73 -0
- package/src/workflows/nodes/actions/gmail.ts +69 -0
- package/src/workflows/nodes/actions/http-request.ts +117 -0
- package/src/workflows/nodes/actions/notification.ts +85 -0
- package/src/workflows/nodes/actions/run-tool.ts +55 -0
- package/src/workflows/nodes/actions/send-message.ts +82 -0
- package/src/workflows/nodes/actions/shell-command.ts +76 -0
- package/src/workflows/nodes/actions/telegram.ts +60 -0
- package/src/workflows/nodes/builtin.ts +119 -0
- package/src/workflows/nodes/error/error-handler.ts +37 -0
- package/src/workflows/nodes/error/fallback.ts +47 -0
- package/src/workflows/nodes/error/retry.ts +82 -0
- package/src/workflows/nodes/logic/delay.ts +42 -0
- package/src/workflows/nodes/logic/if-else.ts +41 -0
- package/src/workflows/nodes/logic/loop.ts +90 -0
- package/src/workflows/nodes/logic/merge.ts +38 -0
- package/src/workflows/nodes/logic/race.ts +40 -0
- package/src/workflows/nodes/logic/switch.ts +59 -0
- package/src/workflows/nodes/logic/template-render.ts +53 -0
- package/src/workflows/nodes/logic/variable-get.ts +37 -0
- package/src/workflows/nodes/logic/variable-set.ts +59 -0
- package/src/workflows/nodes/registry.ts +99 -0
- package/src/workflows/nodes/transform/aggregate.ts +99 -0
- package/src/workflows/nodes/transform/csv-parse.ts +70 -0
- package/src/workflows/nodes/transform/json-parse.ts +63 -0
- package/src/workflows/nodes/transform/map-filter.ts +84 -0
- package/src/workflows/nodes/transform/regex-match.ts +89 -0
- package/src/workflows/nodes/triggers/calendar.ts +33 -0
- package/src/workflows/nodes/triggers/clipboard.ts +32 -0
- package/src/workflows/nodes/triggers/cron.ts +40 -0
- package/src/workflows/nodes/triggers/email.ts +40 -0
- package/src/workflows/nodes/triggers/file-change.ts +45 -0
- package/src/workflows/nodes/triggers/git.ts +46 -0
- package/src/workflows/nodes/triggers/manual.ts +23 -0
- package/src/workflows/nodes/triggers/poll.ts +81 -0
- package/src/workflows/nodes/triggers/process.ts +44 -0
- package/src/workflows/nodes/triggers/screen-event.ts +37 -0
- package/src/workflows/nodes/triggers/webhook.ts +39 -0
- package/src/workflows/safe-eval.ts +139 -0
- package/src/workflows/template.ts +118 -0
- package/src/workflows/triggers/cron.ts +311 -0
- package/src/workflows/triggers/manager.ts +285 -0
- package/src/workflows/triggers/observer-bridge.ts +172 -0
- package/src/workflows/triggers/poller.ts +201 -0
- package/src/workflows/triggers/screen-condition.ts +218 -0
- package/src/workflows/triggers/triggers.test.ts +740 -0
- package/src/workflows/triggers/webhook.ts +191 -0
- package/src/workflows/types.ts +133 -0
- package/src/workflows/variables.ts +72 -0
- package/src/workflows/workflows.test.ts +383 -0
- package/src/workflows/yaml.ts +104 -0
- package/ui/dist/index-3gr23jt9.js +112614 -0
- package/ui/dist/index-9vmj8127.css +14239 -0
- package/ui/dist/index-hy9pc1gm.js +112873 -0
- package/ui/dist/index-j2ep5d1w.js +112374 -0
- package/ui/dist/index-jt00vjqs.js +112858 -0
- package/ui/dist/index-k9ymx5qb.js +112374 -0
- package/ui/dist/index.html +16 -0
- package/ui/public/audio/pcm-capture-processor.js +11 -0
- package/ui/public/openwakeword/models/embedding_model.onnx +0 -0
- package/ui/public/openwakeword/models/hey_jarvis_v0.1.onnx +0 -0
- package/ui/public/openwakeword/models/melspectrogram.onnx +0 -0
- package/ui/public/openwakeword/models/silero_vad.onnx +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.mjs +106 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.mjs +59 -0
- package/ui/public/ort/ort-wasm-simd-threaded.wasm +0 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket } from 'bun';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { SidecarManager } from '../sidecar/manager.ts';
|
|
4
|
+
import {
|
|
5
|
+
getCookie,
|
|
6
|
+
getDashboardSessionFromRequest,
|
|
7
|
+
safeCompare,
|
|
8
|
+
} from './dashboard-auth.ts';
|
|
9
|
+
|
|
10
|
+
export type WSMessage = {
|
|
11
|
+
type: 'chat' | 'command' | 'status' | 'stream' | 'error' | 'notification'
|
|
12
|
+
| 'tts_start' | 'tts_end' | 'voice_start' | 'voice_end'
|
|
13
|
+
| 'workflow_event'
|
|
14
|
+
| 'goal_event'
|
|
15
|
+
| 'site_event';
|
|
16
|
+
payload: unknown;
|
|
17
|
+
id?: string;
|
|
18
|
+
priority?: 'urgent' | 'normal' | 'low';
|
|
19
|
+
timestamp: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type WSClientHandler = {
|
|
23
|
+
onMessage: (msg: WSMessage, ws: ServerWebSocket<unknown>) => Promise<WSMessage | void>;
|
|
24
|
+
onBinaryMessage?: (data: Buffer, ws: ServerWebSocket<unknown>) => Promise<void>;
|
|
25
|
+
onConnect: (ws: ServerWebSocket<unknown>) => void;
|
|
26
|
+
onDisconnect: (ws: ServerWebSocket<unknown>) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type RouteHandler = (req: Request) => Response | Promise<Response>;
|
|
30
|
+
type MethodRoutes = { [method: string]: RouteHandler };
|
|
31
|
+
|
|
32
|
+
/** 401 HTML page loaded from auth-error.html */
|
|
33
|
+
const AUTH_ERROR_HTML = await Bun.file(path.join(import.meta.dir, 'auth-error.html')).text();
|
|
34
|
+
|
|
35
|
+
/** Inline script injected into authed HTML pages — strips ?token= from the hash. */
|
|
36
|
+
const TOKEN_STRIP_SCRIPT = `<script>(function(){var h=location.hash,i=h.indexOf('?');if(i===-1)return;var p=new URLSearchParams(h.slice(i));if(!p.has('token'))return;p.delete('token');var c=h.slice(0,i),r=p.toString();if(r)c+='?'+r;location.replace(location.pathname+location.search+c)})()</script>`;
|
|
37
|
+
|
|
38
|
+
function isPublicRoute(pathname: string, method: string): boolean {
|
|
39
|
+
return (
|
|
40
|
+
pathname === '/health' ||
|
|
41
|
+
pathname === '/sidecar/connect' ||
|
|
42
|
+
pathname === '/api/sidecars/.well-known/jwks.json' ||
|
|
43
|
+
pathname === '/api/auth/login' ||
|
|
44
|
+
pathname === '/api/auth/logout' ||
|
|
45
|
+
pathname === '/api/auth/session' ||
|
|
46
|
+
pathname === '/api/auth/google/callback' ||
|
|
47
|
+
pathname.startsWith('/api/webhooks/') ||
|
|
48
|
+
method === 'OPTIONS'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Simple sliding-window rate limiter for proxy requests */
|
|
53
|
+
class ProxyRateLimiter {
|
|
54
|
+
private windowMs: number;
|
|
55
|
+
private maxRequests: number;
|
|
56
|
+
private requests: number[] = [];
|
|
57
|
+
|
|
58
|
+
constructor(windowMs = 10_000, maxRequests = 200) {
|
|
59
|
+
this.windowMs = windowMs;
|
|
60
|
+
this.maxRequests = maxRequests;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
allow(): boolean {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
// Evict stale entries
|
|
66
|
+
while (this.requests.length > 0 && this.requests[0]! < now - this.windowMs) {
|
|
67
|
+
this.requests.shift();
|
|
68
|
+
}
|
|
69
|
+
if (this.requests.length >= this.maxRequests) return false;
|
|
70
|
+
this.requests.push(now);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class WebSocketServer {
|
|
76
|
+
private server: Server<any> | null = null;
|
|
77
|
+
private clients: Set<ServerWebSocket<unknown>> = new Set();
|
|
78
|
+
private handler: WSClientHandler | null = null;
|
|
79
|
+
private port: number;
|
|
80
|
+
private startTime: number = 0;
|
|
81
|
+
private apiRoutes: Map<string, MethodRoutes> = new Map();
|
|
82
|
+
private staticDir: string | null = null;
|
|
83
|
+
private publicDir: string | null = null;
|
|
84
|
+
private sidecarManager: SidecarManager | null = null;
|
|
85
|
+
private authToken: string | null = null;
|
|
86
|
+
private dashboardPasswordHash: string | null = null;
|
|
87
|
+
private corsOrigin: string | null = null;
|
|
88
|
+
private proxyLimiter = new ProxyRateLimiter();
|
|
89
|
+
|
|
90
|
+
constructor(port: number = 3142) {
|
|
91
|
+
this.port = port;
|
|
92
|
+
this.corsOrigin = `http://localhost:${port}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setAuthToken(token: string): void {
|
|
96
|
+
this.authToken = token;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setDashboardPasswordHash(passwordHash?: string | null): void {
|
|
100
|
+
this.dashboardPasswordHash = passwordHash?.trim() || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setHandler(handler: WSClientHandler): void {
|
|
104
|
+
this.handler = handler;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setSidecarManager(manager: SidecarManager): void {
|
|
108
|
+
this.sidecarManager = manager;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private siteProxy: import('../sites/proxy.ts').SiteProxy | null = null;
|
|
112
|
+
|
|
113
|
+
setSiteProxy(proxy: import('../sites/proxy.ts').SiteProxy): void {
|
|
114
|
+
this.siteProxy = proxy;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Register API route handlers (method-based).
|
|
119
|
+
* Example: setApiRoutes({ '/api/health': { GET: handler } })
|
|
120
|
+
*/
|
|
121
|
+
setApiRoutes(routes: Record<string, MethodRoutes>): void {
|
|
122
|
+
for (const [path, methods] of Object.entries(routes)) {
|
|
123
|
+
this.apiRoutes.set(path, methods);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Set directory for serving static files (pre-built dashboard).
|
|
129
|
+
*/
|
|
130
|
+
setStaticDir(dir: string): void {
|
|
131
|
+
this.staticDir = dir;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Set directory for serving public assets (models, WASM, etc.).
|
|
136
|
+
* Falls through to this if file not found in staticDir.
|
|
137
|
+
*/
|
|
138
|
+
setPublicDir(dir: string): void {
|
|
139
|
+
this.publicDir = dir;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
start(): void {
|
|
143
|
+
if (this.server) {
|
|
144
|
+
console.warn('[WebSocketServer] Server already running');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.startTime = Date.now();
|
|
149
|
+
const self = this;
|
|
150
|
+
|
|
151
|
+
this.server = Bun.serve<{ sidecar_id?: string; proxy_target?: string; _proxyUpstream?: WebSocket }>({
|
|
152
|
+
port: this.port,
|
|
153
|
+
idleTimeout: 30, // seconds — prevent timeout during heavy processing (OCR, PowerShell)
|
|
154
|
+
|
|
155
|
+
async fetch(req, server) {
|
|
156
|
+
const url = new URL(req.url);
|
|
157
|
+
const pathname = url.pathname;
|
|
158
|
+
|
|
159
|
+
// 0. Sidecar WebSocket upgrade (has its own JWT auth)
|
|
160
|
+
if (pathname === '/sidecar/connect' && self.sidecarManager) {
|
|
161
|
+
const authHeader = req.headers.get('Authorization');
|
|
162
|
+
const token = authHeader?.startsWith('Bearer ')
|
|
163
|
+
? authHeader.slice(7)
|
|
164
|
+
: null;
|
|
165
|
+
if (!token) {
|
|
166
|
+
return new Response('Missing token', { status: 401 });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const claims = await self.sidecarManager.validateToken(token);
|
|
170
|
+
if (!claims) {
|
|
171
|
+
return new Response('Invalid or revoked token', { status: 403 });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const success = server.upgrade(req, { data: { sidecar_id: claims.sid } });
|
|
175
|
+
if (success) return undefined;
|
|
176
|
+
return new Response('WebSocket upgrade failed', { status: 500 });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 1. Auth check (if configured)
|
|
180
|
+
const tokenAuthEnabled = Boolean(self.authToken);
|
|
181
|
+
const passwordAuthEnabled = Boolean(self.dashboardPasswordHash);
|
|
182
|
+
const dashboardAuthEnabled = tokenAuthEnabled || passwordAuthEnabled;
|
|
183
|
+
const cookieToken = tokenAuthEnabled ? getCookie(req, 'token') : null;
|
|
184
|
+
const hasValidToken = Boolean(
|
|
185
|
+
tokenAuthEnabled &&
|
|
186
|
+
self.authToken &&
|
|
187
|
+
cookieToken &&
|
|
188
|
+
safeCompare(cookieToken, self.authToken),
|
|
189
|
+
);
|
|
190
|
+
const queryToken = tokenAuthEnabled ? url.searchParams.get('token') : null;
|
|
191
|
+
const hasValidQueryToken = Boolean(
|
|
192
|
+
tokenAuthEnabled &&
|
|
193
|
+
self.authToken &&
|
|
194
|
+
queryToken &&
|
|
195
|
+
safeCompare(queryToken, self.authToken),
|
|
196
|
+
);
|
|
197
|
+
const session = passwordAuthEnabled ? getDashboardSessionFromRequest(req) : null;
|
|
198
|
+
const isAuthenticated = hasValidToken || Boolean(session);
|
|
199
|
+
const wantsJsonUnauthorized = pathname.startsWith('/api/') || pathname === '/ws';
|
|
200
|
+
|
|
201
|
+
if (dashboardAuthEnabled && !isPublicRoute(pathname, req.method) && hasValidQueryToken) {
|
|
202
|
+
const cleanParams = new URLSearchParams(url.searchParams);
|
|
203
|
+
cleanParams.delete('token');
|
|
204
|
+
const qs = cleanParams.toString();
|
|
205
|
+
const redirectTo = pathname + (qs ? '?' + qs : '');
|
|
206
|
+
return new Response(null, {
|
|
207
|
+
status: 302,
|
|
208
|
+
headers: {
|
|
209
|
+
'Location': redirectTo || '/',
|
|
210
|
+
'Set-Cookie': `token=${queryToken}; Path=/; SameSite=Lax; HttpOnly`,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (dashboardAuthEnabled && !isPublicRoute(pathname, req.method)) {
|
|
216
|
+
if (wantsJsonUnauthorized && !isAuthenticated) {
|
|
217
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
218
|
+
}
|
|
219
|
+
if (!passwordAuthEnabled && !isAuthenticated) {
|
|
220
|
+
return new Response(AUTH_ERROR_HTML, {
|
|
221
|
+
status: 401,
|
|
222
|
+
headers: { 'Content-Type': 'text/html' },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
// Check ?token= query param — set cookie via Set-Cookie and redirect
|
|
229
|
+
/*
|
|
230
|
+
const queryToken = url.searchParams.get('token');
|
|
231
|
+
if (queryToken && safeCompare(queryToken, self.authToken)) {
|
|
232
|
+
const cleanParams = new URLSearchParams(url.searchParams);
|
|
233
|
+
cleanParams.delete('token');
|
|
234
|
+
const qs = cleanParams.toString();
|
|
235
|
+
const redirectTo = pathname + (qs ? '?' + qs : '');
|
|
236
|
+
return new Response(null, {
|
|
237
|
+
status: 302,
|
|
238
|
+
headers: {
|
|
239
|
+
'Location': redirectTo || '/',
|
|
240
|
+
'Set-Cookie': `token=${queryToken}; Path=/; SameSite=Lax; HttpOnly`,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// No valid auth — API & WebSocket get JSON 401; browsers get the auth error page
|
|
245
|
+
if (pathname.startsWith('/api/') || pathname === '/ws') {
|
|
246
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
247
|
+
}
|
|
248
|
+
return new Response(AUTH_ERROR_HTML, {
|
|
249
|
+
status: 401,
|
|
250
|
+
headers: { 'Content-Type': 'text/html' },
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
*/
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
// 2. WebSocket upgrade — validate Origin to block cross-origin connections
|
|
257
|
+
// (e.g., dev server iframes on different ports attempting ws://localhost:3142/ws)
|
|
258
|
+
if (pathname === '/ws') {
|
|
259
|
+
const origin = req.headers.get('origin');
|
|
260
|
+
const expectedOrigin = self.corsOrigin || `http://localhost:${self.port}`;
|
|
261
|
+
if (origin && origin !== expectedOrigin) {
|
|
262
|
+
return new Response('Forbidden: origin mismatch', { status: 403 });
|
|
263
|
+
}
|
|
264
|
+
const success = server.upgrade(req, { data: {} });
|
|
265
|
+
if (success) return undefined;
|
|
266
|
+
return new Response('WebSocket upgrade failed', { status: 500 });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 3. Health check (always public)
|
|
270
|
+
if (pathname === '/health') {
|
|
271
|
+
return Response.json({
|
|
272
|
+
status: 'ok',
|
|
273
|
+
uptime: Date.now() - self.startTime,
|
|
274
|
+
clients: self.clients.size,
|
|
275
|
+
timestamp: Date.now(),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 3b. Site builder proxy — intercept before API route matching
|
|
280
|
+
if (self.siteProxy && pathname.startsWith('/api/sites/') && pathname.includes('/proxy')) {
|
|
281
|
+
const match = self.siteProxy.matchProxy(pathname);
|
|
282
|
+
if (match) {
|
|
283
|
+
// Rate limit proxy requests
|
|
284
|
+
if (!self.proxyLimiter.allow()) {
|
|
285
|
+
return new Response(JSON.stringify({ error: 'Too many proxy requests' }), {
|
|
286
|
+
status: 429,
|
|
287
|
+
headers: { 'Content-Type': 'application/json', 'Retry-After': '10' },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
// WebSocket upgrade for HMR — bridge to dev server
|
|
291
|
+
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
292
|
+
const targetUrl = self.siteProxy.getWebSocketTarget(match.projectId, match.subPath);
|
|
293
|
+
if (!targetUrl) {
|
|
294
|
+
return new Response('Dev server not running', { status: 502 });
|
|
295
|
+
}
|
|
296
|
+
const success = server.upgrade(req, {
|
|
297
|
+
data: { proxy_target: targetUrl },
|
|
298
|
+
});
|
|
299
|
+
if (success) return undefined;
|
|
300
|
+
return new Response('WebSocket upgrade failed', { status: 500 });
|
|
301
|
+
}
|
|
302
|
+
// HTTP proxy
|
|
303
|
+
return self.siteProxy.proxyHttp(req, match.projectId, match.subPath);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 4. API routes
|
|
308
|
+
if (pathname.startsWith('/api/')) {
|
|
309
|
+
// Handle CORS preflight
|
|
310
|
+
if (req.method === 'OPTIONS') {
|
|
311
|
+
const allowedOrigin = self.corsOrigin || `http://localhost:${self.port}`;
|
|
312
|
+
return new Response(null, {
|
|
313
|
+
status: 204,
|
|
314
|
+
headers: {
|
|
315
|
+
'Access-Control-Allow-Origin': allowedOrigin,
|
|
316
|
+
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
|
317
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Try exact match first
|
|
323
|
+
const exactRoute = self.apiRoutes.get(pathname);
|
|
324
|
+
if (exactRoute) {
|
|
325
|
+
const handler = exactRoute[req.method];
|
|
326
|
+
if (handler) return handler(req);
|
|
327
|
+
return new Response('Method Not Allowed', { status: 405 });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Try parameterized routes (e.g., /api/vault/entities/:id)
|
|
331
|
+
for (const [pattern, methods] of self.apiRoutes) {
|
|
332
|
+
const params = matchRoute(pattern, pathname);
|
|
333
|
+
if (params) {
|
|
334
|
+
const handler = methods[req.method];
|
|
335
|
+
if (handler) {
|
|
336
|
+
// Attach params to request
|
|
337
|
+
(req as any).params = params;
|
|
338
|
+
return handler(req);
|
|
339
|
+
}
|
|
340
|
+
return new Response('Method Not Allowed', { status: 405 });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return Response.json({ error: 'Not found' }, { status: 404 });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 5a. Overlay widget (served from ui/ source, not dist/)
|
|
348
|
+
if (pathname === '/overlay' && self.staticDir) {
|
|
349
|
+
if (dashboardAuthEnabled && !isAuthenticated) {
|
|
350
|
+
return new Response(AUTH_ERROR_HTML, {
|
|
351
|
+
status: 401,
|
|
352
|
+
headers: { 'Content-Type': 'text/html' },
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// overlay.html lives in the ui/ source directory (parent of dist/)
|
|
356
|
+
const overlayPath = path.join(self.staticDir, '..', 'overlay.html');
|
|
357
|
+
const overlayFile = Bun.file(overlayPath);
|
|
358
|
+
if (await overlayFile.exists()) {
|
|
359
|
+
if (self.authToken) {
|
|
360
|
+
const html = await overlayFile.text();
|
|
361
|
+
return new Response(injectTokenStrip(html), { headers: { 'Content-Type': 'text/html' } });
|
|
362
|
+
}
|
|
363
|
+
return new Response(overlayFile, { headers: { 'Content-Type': 'text/html' } });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 5b. Static files (dashboard)
|
|
368
|
+
if (self.staticDir) {
|
|
369
|
+
let filePath: string;
|
|
370
|
+
|
|
371
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
372
|
+
filePath = path.join(self.staticDir, 'index.html');
|
|
373
|
+
} else {
|
|
374
|
+
// Serve JS/CSS/assets — resolve and validate within staticDir
|
|
375
|
+
filePath = path.resolve(self.staticDir, '.' + pathname);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Prevent path traversal outside staticDir
|
|
379
|
+
if (!filePath.startsWith(path.resolve(self.staticDir) + path.sep) && filePath !== path.resolve(self.staticDir, 'index.html')) {
|
|
380
|
+
return new Response('Forbidden', { status: 403 });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const file = Bun.file(filePath);
|
|
384
|
+
if (await file.exists()) {
|
|
385
|
+
if (self.authToken && filePath.endsWith('.html')) {
|
|
386
|
+
const html = await file.text();
|
|
387
|
+
return new Response(injectTokenStrip(html), { headers: { 'Content-Type': 'text/html' } });
|
|
388
|
+
}
|
|
389
|
+
return new Response(file);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// 6. Public assets fallback (models, WASM, etc.)
|
|
394
|
+
if (self.publicDir) {
|
|
395
|
+
const publicPath = path.resolve(self.publicDir, '.' + pathname);
|
|
396
|
+
// Prevent path traversal outside publicDir
|
|
397
|
+
if (!publicPath.startsWith(path.resolve(self.publicDir) + path.sep)) {
|
|
398
|
+
return new Response('Forbidden', { status: 403 });
|
|
399
|
+
}
|
|
400
|
+
const publicFile = Bun.file(publicPath);
|
|
401
|
+
if (await publicFile.exists()) {
|
|
402
|
+
return new Response(publicFile);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 7. Site builder catch-all — proxy unmatched paths to the active
|
|
407
|
+
// dev server using the __proj cookie set by the explicit proxy route.
|
|
408
|
+
// This handles absolute paths (/src/main.tsx, /node_modules/...) that
|
|
409
|
+
// frameworks emit and that don't match any JARVIS route.
|
|
410
|
+
if (self.siteProxy) {
|
|
411
|
+
if (dashboardAuthEnabled && !isAuthenticated) {
|
|
412
|
+
return new Response('Unauthorized', { status: 401 });
|
|
413
|
+
}
|
|
414
|
+
// WebSocket upgrade (e.g. Vite HMR)
|
|
415
|
+
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
416
|
+
const targetUrl = self.siteProxy.getWebSocketTargetFromCookie(req, pathname);
|
|
417
|
+
if (targetUrl) {
|
|
418
|
+
const success = server.upgrade(req, { data: { proxy_target: targetUrl } });
|
|
419
|
+
if (success) return undefined;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// HTTP
|
|
423
|
+
const proxyResp = await self.siteProxy.proxyCatchAll(req, pathname + url.search);
|
|
424
|
+
if (proxyResp) return proxyResp;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return new Response('Not Found', { status: 404 });
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
websocket: {
|
|
431
|
+
// Limit individual WS messages to 16 MB (defense against abusive HMR payloads)
|
|
432
|
+
maxPayloadLength: 16 * 1024 * 1024,
|
|
433
|
+
|
|
434
|
+
open(ws) {
|
|
435
|
+
// HMR proxy WebSocket — bridge to dev server
|
|
436
|
+
const proxyTarget = (ws.data as any)?.proxy_target as string | undefined;
|
|
437
|
+
if (proxyTarget) {
|
|
438
|
+
const upstream = new WebSocket(proxyTarget);
|
|
439
|
+
(ws.data as any)._proxyUpstream = upstream;
|
|
440
|
+
upstream.onmessage = (e) => {
|
|
441
|
+
try {
|
|
442
|
+
// Enforce size limit on upstream messages too
|
|
443
|
+
const data = e.data;
|
|
444
|
+
const size = typeof data === 'string' ? data.length : (data as ArrayBuffer).byteLength ?? 0;
|
|
445
|
+
if (size > 16 * 1024 * 1024) return; // drop oversized frames
|
|
446
|
+
ws.send(data);
|
|
447
|
+
} catch { /* client gone */ }
|
|
448
|
+
};
|
|
449
|
+
upstream.onerror = () => { try { ws.close(); } catch { /* ignore */ } };
|
|
450
|
+
upstream.onclose = () => { try { ws.close(); } catch { /* ignore */ } };
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const sidecarId = (ws.data as any)?.sidecar_id as string | undefined;
|
|
455
|
+
if (sidecarId && self.sidecarManager) {
|
|
456
|
+
self.sidecarManager.handleSidecarConnect(ws, sidecarId);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
self.clients.add(ws);
|
|
461
|
+
console.log('[WebSocketServer] Client connected. Total clients:', self.clients.size);
|
|
462
|
+
self.handler?.onConnect(ws);
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
async message(ws, message) {
|
|
466
|
+
// HMR proxy — forward to upstream dev server
|
|
467
|
+
const proxyUpstream = (ws.data as any)?._proxyUpstream as WebSocket | undefined;
|
|
468
|
+
if (proxyUpstream) {
|
|
469
|
+
if (proxyUpstream.readyState === WebSocket.OPEN) {
|
|
470
|
+
proxyUpstream.send(message);
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const sidecarId = (ws.data as any)?.sidecar_id as string | undefined;
|
|
476
|
+
if (sidecarId && self.sidecarManager) {
|
|
477
|
+
self.sidecarManager.handleSidecarMessage(ws, message);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Binary frame = audio data (mic audio from client)
|
|
482
|
+
if (message instanceof Buffer) {
|
|
483
|
+
if (self.handler?.onBinaryMessage) {
|
|
484
|
+
try {
|
|
485
|
+
await self.handler.onBinaryMessage(message, ws);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
console.error('[WebSocketServer] Error processing binary message:', error);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Text frame = JSON message (existing protocol)
|
|
494
|
+
try {
|
|
495
|
+
const msg: WSMessage = JSON.parse(message.toString());
|
|
496
|
+
console.log('[WebSocketServer] Received:', msg.type, msg.id);
|
|
497
|
+
|
|
498
|
+
if (self.handler) {
|
|
499
|
+
const response = await self.handler.onMessage(msg, ws);
|
|
500
|
+
if (response) {
|
|
501
|
+
ws.send(JSON.stringify(response));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} catch (error) {
|
|
505
|
+
console.error('[WebSocketServer] Error processing message:', error);
|
|
506
|
+
const errorMsg: WSMessage = {
|
|
507
|
+
type: 'error',
|
|
508
|
+
payload: {
|
|
509
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
510
|
+
},
|
|
511
|
+
timestamp: Date.now(),
|
|
512
|
+
};
|
|
513
|
+
ws.send(JSON.stringify(errorMsg));
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
pong(ws) {
|
|
518
|
+
const sidecarId = (ws.data as any)?.sidecar_id as string | undefined;
|
|
519
|
+
if (sidecarId && self.sidecarManager) {
|
|
520
|
+
self.sidecarManager.handleSidecarPong(sidecarId);
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
close(ws) {
|
|
525
|
+
// HMR proxy cleanup
|
|
526
|
+
const proxyUpstream = (ws.data as any)?._proxyUpstream as WebSocket | undefined;
|
|
527
|
+
if (proxyUpstream) {
|
|
528
|
+
try { proxyUpstream.close(); } catch { /* ignore */ }
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const sidecarId = (ws.data as any)?.sidecar_id as string | undefined;
|
|
533
|
+
if (sidecarId && self.sidecarManager) {
|
|
534
|
+
self.sidecarManager.handleSidecarDisconnect(sidecarId);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
self.clients.delete(ws);
|
|
539
|
+
console.log('[WebSocketServer] Client disconnected. Total clients:', self.clients.size);
|
|
540
|
+
self.handler?.onDisconnect(ws);
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
console.log(`[WebSocketServer] Started on ws://localhost:${this.port}/ws`);
|
|
546
|
+
console.log(`[WebSocketServer] Health endpoint: http://localhost:${this.port}/health`);
|
|
547
|
+
if (this.staticDir) {
|
|
548
|
+
console.log(`[WebSocketServer] Dashboard: http://localhost:${this.port}/`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
stop(): void {
|
|
553
|
+
if (this.server) {
|
|
554
|
+
this.server.stop();
|
|
555
|
+
this.server = null;
|
|
556
|
+
this.clients.clear();
|
|
557
|
+
console.log('[WebSocketServer] Stopped');
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
broadcast(message: WSMessage): void {
|
|
562
|
+
const payload = JSON.stringify(message);
|
|
563
|
+
let sent = 0;
|
|
564
|
+
|
|
565
|
+
for (const client of this.clients) {
|
|
566
|
+
try {
|
|
567
|
+
client.send(payload);
|
|
568
|
+
sent++;
|
|
569
|
+
} catch (error) {
|
|
570
|
+
console.error('[WebSocketServer] Error broadcasting to client:', error);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Only log errors or when no clients received the message
|
|
575
|
+
if (sent === 0 && this.clients.size > 0) {
|
|
576
|
+
console.warn(`[WebSocketServer] Broadcast failed: 0/${this.clients.size} clients received message`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
send(client: ServerWebSocket<unknown>, message: WSMessage): void {
|
|
581
|
+
try {
|
|
582
|
+
client.send(JSON.stringify(message));
|
|
583
|
+
} catch (error) {
|
|
584
|
+
console.error('[WebSocketServer] Error sending to client:', error);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Unicast a JSON message to a specific client (e.g. tts_start/tts_end signals).
|
|
590
|
+
*/
|
|
591
|
+
sendToClient(ws: ServerWebSocket<unknown>, message: WSMessage): void {
|
|
592
|
+
try {
|
|
593
|
+
ws.send(JSON.stringify(message));
|
|
594
|
+
} catch (error) {
|
|
595
|
+
console.error('[WebSocketServer] Error unicasting to client:', error);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Unicast binary data to a specific client (e.g. TTS audio chunks).
|
|
601
|
+
*/
|
|
602
|
+
sendBinary(ws: ServerWebSocket<unknown>, data: Buffer): void {
|
|
603
|
+
try {
|
|
604
|
+
ws.sendBinary(data);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
console.error('[WebSocketServer] Error sending binary to client:', error);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
isRunning(): boolean {
|
|
611
|
+
return this.server !== null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
getPort(): number {
|
|
615
|
+
return this.port;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
getClientCount(): number {
|
|
619
|
+
return this.clients.size;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
getClients(): Set<ServerWebSocket<unknown>> {
|
|
623
|
+
return this.clients;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Inject the token-stripping script into an HTML page (right after <head>).
|
|
629
|
+
*/
|
|
630
|
+
function injectTokenStrip(html: string): string {
|
|
631
|
+
const headIdx = html.indexOf('<head>');
|
|
632
|
+
if (headIdx !== -1) {
|
|
633
|
+
return html.slice(0, headIdx + 6) + TOKEN_STRIP_SCRIPT + html.slice(headIdx + 6);
|
|
634
|
+
}
|
|
635
|
+
const htmlIdx = html.indexOf('<html');
|
|
636
|
+
if (htmlIdx !== -1) {
|
|
637
|
+
const closeTag = html.indexOf('>', htmlIdx);
|
|
638
|
+
if (closeTag !== -1) {
|
|
639
|
+
return html.slice(0, closeTag + 1) + TOKEN_STRIP_SCRIPT + html.slice(closeTag + 1);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return TOKEN_STRIP_SCRIPT + html;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Match a route pattern like '/api/vault/entities/:id/facts' against a pathname.
|
|
647
|
+
* Returns params object if matched, null otherwise.
|
|
648
|
+
*/
|
|
649
|
+
function matchRoute(pattern: string, pathname: string): Record<string, string> | null {
|
|
650
|
+
// Skip wildcard patterns
|
|
651
|
+
if (pattern.includes('*')) return null;
|
|
652
|
+
|
|
653
|
+
const patternParts = pattern.split('/');
|
|
654
|
+
const pathParts = pathname.split('/');
|
|
655
|
+
|
|
656
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
657
|
+
|
|
658
|
+
const params: Record<string, string> = {};
|
|
659
|
+
|
|
660
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
661
|
+
if (patternParts[i]!.startsWith(':')) {
|
|
662
|
+
params[patternParts[i]!.slice(1)] = pathParts[i]!;
|
|
663
|
+
} else if (patternParts[i] !== pathParts[i]) {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return Object.keys(params).length > 0 ? params : null;
|
|
669
|
+
}
|