@qwickapps/server 1.1.9 → 1.3.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 +318 -0
- package/dist/core/control-panel.d.ts +7 -2
- package/dist/core/control-panel.d.ts.map +1 -1
- package/dist/core/control-panel.js +99 -60
- package/dist/core/control-panel.js.map +1 -1
- package/dist/core/gateway.d.ts +159 -79
- package/dist/core/gateway.d.ts.map +1 -1
- package/dist/core/gateway.js +683 -315
- package/dist/core/gateway.js.map +1 -1
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/plugin-registry.d.ts +271 -0
- package/dist/core/plugin-registry.d.ts.map +1 -0
- package/dist/core/plugin-registry.js +326 -0
- package/dist/core/plugin-registry.js.map +1 -0
- package/dist/core/types.d.ts +16 -33
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/auth/adapters/auth0-adapter.d.ts +14 -0
- package/dist/plugins/auth/adapters/auth0-adapter.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/auth0-adapter.js +179 -0
- package/dist/plugins/auth/adapters/auth0-adapter.js.map +1 -0
- package/dist/plugins/auth/adapters/basic-adapter.d.ts +13 -0
- package/dist/plugins/auth/adapters/basic-adapter.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/basic-adapter.js +51 -0
- package/dist/plugins/auth/adapters/basic-adapter.js.map +1 -0
- package/dist/plugins/auth/adapters/index.d.ts +9 -0
- package/dist/plugins/auth/adapters/index.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/index.js +9 -0
- package/dist/plugins/auth/adapters/index.js.map +1 -0
- package/dist/plugins/auth/adapters/supabase-adapter.d.ts +13 -0
- package/dist/plugins/auth/adapters/supabase-adapter.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/supabase-adapter.js +109 -0
- package/dist/plugins/auth/adapters/supabase-adapter.js.map +1 -0
- package/dist/plugins/auth/auth-plugin.d.ts +40 -0
- package/dist/plugins/auth/auth-plugin.d.ts.map +1 -0
- package/dist/plugins/auth/auth-plugin.js +255 -0
- package/dist/plugins/auth/auth-plugin.js.map +1 -0
- package/dist/plugins/auth/auth-plugin.test.d.ts +9 -0
- package/dist/plugins/auth/auth-plugin.test.d.ts.map +1 -0
- package/dist/plugins/auth/auth-plugin.test.js +147 -0
- package/dist/plugins/auth/auth-plugin.test.js.map +1 -0
- package/dist/plugins/auth/index.d.ts +12 -0
- package/dist/plugins/auth/index.d.ts.map +1 -0
- package/dist/plugins/auth/index.js +13 -0
- package/dist/plugins/auth/index.js.map +1 -0
- package/dist/plugins/auth/types.d.ts +148 -0
- package/dist/plugins/auth/types.d.ts.map +1 -0
- package/dist/plugins/auth/types.js +14 -0
- package/dist/plugins/auth/types.js.map +1 -0
- package/dist/plugins/bans/bans-plugin.d.ts +59 -0
- package/dist/plugins/bans/bans-plugin.d.ts.map +1 -0
- package/dist/plugins/bans/bans-plugin.js +428 -0
- package/dist/plugins/bans/bans-plugin.js.map +1 -0
- package/dist/plugins/bans/index.d.ts +9 -0
- package/dist/plugins/bans/index.d.ts.map +1 -0
- package/dist/plugins/bans/index.js +10 -0
- package/dist/plugins/bans/index.js.map +1 -0
- package/dist/plugins/bans/stores/index.d.ts +7 -0
- package/dist/plugins/bans/stores/index.d.ts.map +1 -0
- package/dist/plugins/bans/stores/index.js +7 -0
- package/dist/plugins/bans/stores/index.js.map +1 -0
- package/dist/plugins/bans/stores/postgres-store.d.ts +29 -0
- package/dist/plugins/bans/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/bans/stores/postgres-store.js +132 -0
- package/dist/plugins/bans/stores/postgres-store.js.map +1 -0
- package/dist/plugins/bans/types.d.ts +128 -0
- package/dist/plugins/bans/types.d.ts.map +1 -0
- package/dist/plugins/bans/types.js +11 -0
- package/dist/plugins/bans/types.js.map +1 -0
- package/dist/plugins/cache-plugin.d.ts +14 -3
- package/dist/plugins/cache-plugin.d.ts.map +1 -1
- package/dist/plugins/cache-plugin.js +27 -7
- package/dist/plugins/cache-plugin.js.map +1 -1
- package/dist/plugins/cache-plugin.test.js +96 -32
- package/dist/plugins/cache-plugin.test.js.map +1 -1
- package/dist/plugins/config-plugin.d.ts +3 -2
- package/dist/plugins/config-plugin.d.ts.map +1 -1
- package/dist/plugins/config-plugin.js +17 -10
- package/dist/plugins/config-plugin.js.map +1 -1
- package/dist/plugins/diagnostics-plugin.d.ts +2 -2
- package/dist/plugins/diagnostics-plugin.d.ts.map +1 -1
- package/dist/plugins/diagnostics-plugin.js +17 -10
- package/dist/plugins/diagnostics-plugin.js.map +1 -1
- package/dist/plugins/entitlements/entitlements-plugin.d.ts +95 -0
- package/dist/plugins/entitlements/entitlements-plugin.d.ts.map +1 -0
- package/dist/plugins/entitlements/entitlements-plugin.js +707 -0
- package/dist/plugins/entitlements/entitlements-plugin.js.map +1 -0
- package/dist/plugins/entitlements/index.d.ts +12 -0
- package/dist/plugins/entitlements/index.d.ts.map +1 -0
- package/dist/plugins/entitlements/index.js +16 -0
- package/dist/plugins/entitlements/index.js.map +1 -0
- package/dist/plugins/entitlements/sources/index.d.ts +9 -0
- package/dist/plugins/entitlements/sources/index.d.ts.map +1 -0
- package/dist/plugins/entitlements/sources/index.js +9 -0
- package/dist/plugins/entitlements/sources/index.js.map +1 -0
- package/dist/plugins/entitlements/sources/postgres-source.d.ts +29 -0
- package/dist/plugins/entitlements/sources/postgres-source.d.ts.map +1 -0
- package/dist/plugins/entitlements/sources/postgres-source.js +169 -0
- package/dist/plugins/entitlements/sources/postgres-source.js.map +1 -0
- package/dist/plugins/entitlements/types.d.ts +232 -0
- package/dist/plugins/entitlements/types.d.ts.map +1 -0
- package/dist/plugins/entitlements/types.js +11 -0
- package/dist/plugins/entitlements/types.js.map +1 -0
- package/dist/plugins/frontend-app-plugin.d.ts +9 -3
- package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
- package/dist/plugins/frontend-app-plugin.js +14 -9
- package/dist/plugins/frontend-app-plugin.js.map +1 -1
- package/dist/plugins/health-plugin.d.ts +5 -2
- package/dist/plugins/health-plugin.d.ts.map +1 -1
- package/dist/plugins/health-plugin.js +20 -5
- package/dist/plugins/health-plugin.js.map +1 -1
- package/dist/plugins/index.d.ts +8 -2
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +8 -2
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/logs-plugin.d.ts +3 -2
- package/dist/plugins/logs-plugin.d.ts.map +1 -1
- package/dist/plugins/logs-plugin.js +21 -12
- package/dist/plugins/logs-plugin.js.map +1 -1
- package/dist/plugins/postgres-plugin.d.ts +3 -3
- package/dist/plugins/postgres-plugin.d.ts.map +1 -1
- package/dist/plugins/postgres-plugin.js +9 -7
- package/dist/plugins/postgres-plugin.js.map +1 -1
- package/dist/plugins/postgres-plugin.test.js +47 -29
- package/dist/plugins/postgres-plugin.test.js.map +1 -1
- package/dist/plugins/users/index.d.ts +12 -0
- package/dist/plugins/users/index.d.ts.map +1 -0
- package/dist/plugins/users/index.js +13 -0
- package/dist/plugins/users/index.js.map +1 -0
- package/dist/plugins/users/stores/index.d.ts +7 -0
- package/dist/plugins/users/stores/index.d.ts.map +1 -0
- package/dist/plugins/users/stores/index.js +7 -0
- package/dist/plugins/users/stores/index.js.map +1 -0
- package/dist/plugins/users/stores/postgres-store.d.ts +28 -0
- package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/users/stores/postgres-store.js +157 -0
- package/dist/plugins/users/stores/postgres-store.js.map +1 -0
- package/dist/plugins/users/types.d.ts +189 -0
- package/dist/plugins/users/types.d.ts.map +1 -0
- package/dist/plugins/users/types.js +12 -0
- package/dist/plugins/users/types.js.map +1 -0
- package/dist/plugins/users/users-plugin.d.ts +39 -0
- package/dist/plugins/users/users-plugin.d.ts.map +1 -0
- package/dist/plugins/users/users-plugin.js +242 -0
- package/dist/plugins/users/users-plugin.js.map +1 -0
- package/dist-ui/assets/index-Bsp2ntcw.js +465 -0
- package/dist-ui/assets/index-Bsp2ntcw.js.map +1 -0
- package/dist-ui/index.html +1 -1
- package/dist-ui-lib/api/controlPanelApi.d.ts +232 -0
- package/dist-ui-lib/components/ControlPanelApp.d.ts +61 -0
- package/dist-ui-lib/components/index.d.ts +18 -0
- package/dist-ui-lib/config/AppConfig.d.ts +7 -0
- package/dist-ui-lib/dashboard/DashboardWidgetRegistry.d.ts +62 -0
- package/dist-ui-lib/dashboard/DashboardWidgetRenderer.d.ts +8 -0
- package/dist-ui-lib/dashboard/PluginWidgetRenderer.d.ts +19 -0
- package/dist-ui-lib/dashboard/WidgetComponentRegistry.d.ts +44 -0
- package/dist-ui-lib/dashboard/builtInWidgets.d.ts +19 -0
- package/dist-ui-lib/dashboard/index.d.ts +13 -0
- package/dist-ui-lib/dashboard/widgets/ServiceHealthWidget.d.ts +12 -0
- package/dist-ui-lib/dashboard/widgets/index.d.ts +6 -0
- package/dist-ui-lib/index.js +6441 -0
- package/dist-ui-lib/index.js.map +1 -0
- package/dist-ui-lib/pages/ConfigPage.d.ts +1 -0
- package/dist-ui-lib/pages/DashboardPage.d.ts +1 -0
- package/dist-ui-lib/pages/DiagnosticsPage.d.ts +1 -0
- package/dist-ui-lib/pages/EntitlementsPage.d.ts +17 -0
- package/dist-ui-lib/pages/LogsPage.d.ts +1 -0
- package/dist-ui-lib/pages/NotFoundPage.d.ts +1 -0
- package/dist-ui-lib/pages/PluginPage.d.ts +15 -0
- package/dist-ui-lib/pages/SystemPage.d.ts +1 -0
- package/dist-ui-lib/pages/UsersPage.d.ts +22 -0
- package/package.json +18 -6
- package/src/core/control-panel.ts +122 -68
- package/src/core/gateway.ts +870 -399
- package/src/core/index.ts +21 -2
- package/src/core/plugin-registry.ts +653 -0
- package/src/core/types.ts +31 -37
- package/src/index.ts +118 -19
- package/src/plugins/auth/adapters/auth0-adapter.ts +214 -0
- package/src/plugins/auth/adapters/basic-adapter.ts +61 -0
- package/src/plugins/auth/adapters/index.ts +9 -0
- package/src/plugins/auth/adapters/supabase-adapter.ts +141 -0
- package/src/plugins/auth/auth-plugin.test.ts +176 -0
- package/src/plugins/auth/auth-plugin.ts +303 -0
- package/src/plugins/auth/index.ts +33 -0
- package/src/plugins/auth/types.ts +165 -0
- package/src/plugins/bans/bans-plugin.ts +485 -0
- package/src/plugins/bans/index.ts +31 -0
- package/src/plugins/bans/stores/index.ts +7 -0
- package/src/plugins/bans/stores/postgres-store.ts +195 -0
- package/src/plugins/bans/types.ts +141 -0
- package/src/plugins/cache-plugin.test.ts +105 -32
- package/src/plugins/cache-plugin.ts +40 -9
- package/src/plugins/config-plugin.ts +23 -12
- package/src/plugins/diagnostics-plugin.ts +22 -12
- package/src/plugins/entitlements/entitlements-plugin.ts +820 -0
- package/src/plugins/entitlements/index.ts +51 -0
- package/src/plugins/entitlements/sources/index.ts +9 -0
- package/src/plugins/entitlements/sources/postgres-source.ts +253 -0
- package/src/plugins/entitlements/types.ts +256 -0
- package/src/plugins/frontend-app-plugin.ts +24 -12
- package/src/plugins/health-plugin.ts +27 -7
- package/src/plugins/index.ts +106 -4
- package/src/plugins/logs-plugin.ts +28 -14
- package/src/plugins/postgres-plugin.test.ts +49 -29
- package/src/plugins/postgres-plugin.ts +11 -9
- package/src/plugins/users/index.ts +35 -0
- package/src/plugins/users/stores/index.ts +7 -0
- package/src/plugins/users/stores/postgres-store.ts +225 -0
- package/src/plugins/users/types.ts +209 -0
- package/src/plugins/users/users-plugin.ts +281 -0
- package/ui/src/App.tsx +185 -31
- package/ui/src/api/controlPanelApi.ts +354 -1
- package/ui/src/components/ControlPanelApp.tsx +209 -0
- package/ui/src/components/index.ts +62 -0
- package/ui/src/dashboard/DashboardWidgetRegistry.tsx +129 -0
- package/ui/src/dashboard/DashboardWidgetRenderer.tsx +34 -0
- package/ui/src/dashboard/PluginWidgetRenderer.tsx +115 -0
- package/ui/src/dashboard/WidgetComponentRegistry.tsx +116 -0
- package/ui/src/dashboard/builtInWidgets.tsx +29 -0
- package/ui/src/dashboard/index.ts +35 -0
- package/ui/src/dashboard/widgets/ServiceHealthWidget.tsx +140 -0
- package/ui/src/dashboard/widgets/index.ts +7 -0
- package/ui/src/pages/DashboardPage.tsx +28 -149
- package/ui/src/pages/EntitlementsPage.tsx +557 -0
- package/ui/src/pages/LogsPage.tsx +174 -8
- package/ui/src/pages/PluginPage.tsx +148 -0
- package/ui/src/pages/SystemPage.tsx +445 -0
- package/ui/src/pages/UsersPage.tsx +837 -0
- package/ui/tsconfig.lib.json +11 -0
- package/ui/vite.lib.config.ts +51 -0
- package/dist-ui/assets/index-CW1BviRn.js +0 -465
- package/dist-ui/assets/index-CW1BviRn.js.map +0 -1
- package/ui/src/pages/HealthPage.tsx +0 -204
package/src/core/gateway.ts
CHANGED
|
@@ -2,83 +2,193 @@
|
|
|
2
2
|
* Gateway Server for @qwickapps/server
|
|
3
3
|
*
|
|
4
4
|
* Provides a production-ready gateway pattern that:
|
|
5
|
-
* 1.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
5
|
+
* 1. Proxies multiple apps mounted at configurable paths
|
|
6
|
+
* 2. Each app runs at `/` on its own internal port
|
|
7
|
+
* 3. Gateway handles path rewriting automatically
|
|
8
|
+
* 4. Provides health and diagnostics endpoints
|
|
8
9
|
*
|
|
9
10
|
* Architecture:
|
|
10
|
-
* Internet → Gateway (
|
|
11
|
+
* Internet → Gateway (:3000) → [Control Panel (:3001), Admin (:3002), API (:3003), ...]
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Port Scheme:
|
|
14
|
+
* - 3000: Gateway (public)
|
|
15
|
+
* - 3001: Control Panel
|
|
16
|
+
* - 3002+: Additional apps
|
|
17
|
+
*
|
|
18
|
+
* Each app is isolated and can be served from any mount path without rebuilding.
|
|
14
19
|
*
|
|
15
20
|
* Copyright (c) 2025 QwickApps.com. All rights reserved.
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
|
-
import type { Application
|
|
19
|
-
import type { IncomingMessage, ServerResponse } from 'http';
|
|
23
|
+
import type { Application } from 'express';
|
|
24
|
+
import type { IncomingMessage, ServerResponse, Server } from 'http';
|
|
20
25
|
import type { Socket } from 'net';
|
|
21
|
-
import type {
|
|
22
|
-
import type { ControlPanelConfig,
|
|
26
|
+
import type { Duplex } from 'stream';
|
|
27
|
+
import type { ControlPanelConfig, Logger } from './types.js';
|
|
28
|
+
import type { Plugin, PluginConfig } from './plugin-registry.js';
|
|
23
29
|
import { createControlPanel } from './control-panel.js';
|
|
24
30
|
import { initializeLogging, getControlPanelLogger, type LoggingConfig } from './logging.js';
|
|
25
31
|
import { createProxyMiddleware, type Options } from 'http-proxy-middleware';
|
|
26
32
|
import express from 'express';
|
|
27
|
-
import { existsSync } from 'fs';
|
|
28
|
-
import { resolve } from 'path';
|
|
33
|
+
import { existsSync, readFileSync } from 'fs';
|
|
34
|
+
import { resolve, join, dirname } from 'path';
|
|
35
|
+
import { fileURLToPath } from 'url';
|
|
36
|
+
|
|
37
|
+
// Get QwickApps Server version from package.json
|
|
38
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
39
|
+
const __dirname = dirname(__filename);
|
|
40
|
+
const serverPackageJson = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
41
|
+
const QWICKAPPS_SERVER_VERSION = serverPackageJson.version || '1.0.0';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Maintenance mode configuration for a mounted app
|
|
45
|
+
*/
|
|
46
|
+
export interface MaintenanceConfig {
|
|
47
|
+
/** Enable maintenance mode - blocks all requests with maintenance page */
|
|
48
|
+
enabled: boolean;
|
|
49
|
+
/** Custom page title (default: "Under Maintenance") */
|
|
50
|
+
title?: string;
|
|
51
|
+
/** Custom message to display */
|
|
52
|
+
message?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Expected time when service will be back.
|
|
55
|
+
* Can be ISO date string, relative time ("2 hours", "30 minutes"), or "soon"
|
|
56
|
+
*/
|
|
57
|
+
expectedBackAt?: string;
|
|
58
|
+
/** URL for support/contact (shows "Contact Support" link) */
|
|
59
|
+
contactUrl?: string;
|
|
60
|
+
/** Allow specific paths to bypass maintenance (e.g., ["/api/health", "/api/status"]) */
|
|
61
|
+
bypassPaths?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fallback page configuration when a service is unavailable (proxy error)
|
|
66
|
+
*/
|
|
67
|
+
export interface FallbackConfig {
|
|
68
|
+
/** Custom page title (default: "Service Unavailable") */
|
|
69
|
+
title?: string;
|
|
70
|
+
/** Custom message (default: "This service is temporarily unavailable") */
|
|
71
|
+
message?: string;
|
|
72
|
+
/** Show "Try Again" button (default: true) */
|
|
73
|
+
showRetry?: boolean;
|
|
74
|
+
/** Auto-refresh interval in seconds (0 = disabled, default: 30) */
|
|
75
|
+
autoRefresh?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Configuration for a mounted app
|
|
80
|
+
*/
|
|
81
|
+
export interface MountedAppConfig {
|
|
82
|
+
/** Mount path (e.g., '/admin', '/api') */
|
|
83
|
+
path: string;
|
|
84
|
+
|
|
85
|
+
/** Display name for the app (used in status pages) */
|
|
86
|
+
name?: string;
|
|
87
|
+
|
|
88
|
+
/** App source configuration */
|
|
89
|
+
source:
|
|
90
|
+
| {
|
|
91
|
+
/** Proxy to an internal service */
|
|
92
|
+
type: 'proxy';
|
|
93
|
+
/** Target URL (e.g., 'http://localhost:3002') */
|
|
94
|
+
target: string;
|
|
95
|
+
/** Enable WebSocket proxying */
|
|
96
|
+
ws?: boolean;
|
|
97
|
+
}
|
|
98
|
+
| {
|
|
99
|
+
/** Serve static files */
|
|
100
|
+
type: 'static';
|
|
101
|
+
/** Path to static files directory */
|
|
102
|
+
directory: string;
|
|
103
|
+
/** Enable SPA mode (serve index.html for all routes) */
|
|
104
|
+
spa?: boolean;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Whether to strip the mount path prefix when proxying (default: true) */
|
|
108
|
+
stripPrefix?: boolean;
|
|
109
|
+
|
|
110
|
+
/** Route guard for this app */
|
|
111
|
+
guard?: ControlPanelConfig['guard'];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Maintenance mode configuration.
|
|
115
|
+
* When enabled, all requests to this app show a maintenance page.
|
|
116
|
+
*/
|
|
117
|
+
maintenance?: MaintenanceConfig;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fallback page configuration for when the service is unavailable.
|
|
121
|
+
* Shown when proxy encounters connection errors (service down/crashed).
|
|
122
|
+
*/
|
|
123
|
+
fallback?: FallbackConfig;
|
|
124
|
+
}
|
|
29
125
|
|
|
30
126
|
/**
|
|
31
127
|
* Gateway configuration
|
|
32
128
|
*/
|
|
33
129
|
export interface GatewayConfig {
|
|
34
|
-
/** Port for the gateway (public-facing). Defaults to GATEWAY_PORT env or
|
|
35
|
-
|
|
130
|
+
/** Port for the gateway (public-facing). Defaults to GATEWAY_PORT env or 3000 */
|
|
131
|
+
port?: number;
|
|
36
132
|
|
|
37
|
-
/**
|
|
38
|
-
servicePort?: number;
|
|
39
|
-
|
|
40
|
-
/** Product name for the control panel */
|
|
133
|
+
/** Product name for the gateway */
|
|
41
134
|
productName: string;
|
|
42
135
|
|
|
43
136
|
/** Product version */
|
|
44
137
|
version?: string;
|
|
45
138
|
|
|
46
139
|
/**
|
|
47
|
-
* URL to the product logo
|
|
48
|
-
* Used on
|
|
140
|
+
* URL path to the product logo icon (SVG, PNG, etc.).
|
|
141
|
+
* Used on landing pages and passed to the control panel React UI.
|
|
142
|
+
* Example: '/cpanel/logo.svg'
|
|
49
143
|
*/
|
|
50
|
-
|
|
144
|
+
logoIconUrl?: string;
|
|
51
145
|
|
|
52
|
-
/** Branding configuration */
|
|
146
|
+
/** Branding configuration (primaryColor, favicon) */
|
|
53
147
|
branding?: ControlPanelConfig['branding'];
|
|
54
148
|
|
|
55
149
|
/** CORS origins */
|
|
56
150
|
corsOrigins?: string[];
|
|
57
151
|
|
|
58
|
-
/** Control panel plugins */
|
|
59
|
-
plugins?: ControlPanelPlugin[];
|
|
60
|
-
|
|
61
|
-
/** Quick links for the control panel */
|
|
62
|
-
links?: ControlPanelConfig['links'];
|
|
63
|
-
|
|
64
|
-
/** Path to custom React UI dist folder for the control panel */
|
|
65
|
-
customUiPath?: string;
|
|
66
|
-
|
|
67
152
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
153
|
+
* Mounted apps configuration.
|
|
154
|
+
* Each app runs at `/` on its own port, gateway proxies to it at the configured path.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```typescript
|
|
158
|
+
* apps: [
|
|
159
|
+
* { path: '/cpanel', source: { type: 'proxy', target: 'http://localhost:3001' } },
|
|
160
|
+
* { path: '/admin', source: { type: 'proxy', target: 'http://localhost:3002', ws: true } },
|
|
161
|
+
* { path: '/api', source: { type: 'proxy', target: 'http://localhost:3003' } },
|
|
162
|
+
* { path: '/docs', source: { type: 'static', directory: './dist-docs', spa: true } },
|
|
163
|
+
* ]
|
|
164
|
+
* ```
|
|
70
165
|
*/
|
|
71
|
-
|
|
166
|
+
apps?: MountedAppConfig[];
|
|
72
167
|
|
|
73
168
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
169
|
+
* Control panel configuration.
|
|
170
|
+
* The control panel is a special built-in app that can be enabled/disabled.
|
|
76
171
|
*/
|
|
77
|
-
|
|
172
|
+
controlPanel?: {
|
|
173
|
+
/** Enable the built-in control panel (default: true) */
|
|
174
|
+
enabled?: boolean;
|
|
175
|
+
/** Mount path for control panel (default: '/cpanel') */
|
|
176
|
+
path?: string;
|
|
177
|
+
/** Port for internal control panel server (default: 3001) */
|
|
178
|
+
port?: number;
|
|
179
|
+
/** Control panel plugins */
|
|
180
|
+
plugins?: Array<{ plugin: Plugin; config?: PluginConfig }>;
|
|
181
|
+
/** Quick links */
|
|
182
|
+
links?: ControlPanelConfig['links'];
|
|
183
|
+
/** Custom UI path */
|
|
184
|
+
customUiPath?: string;
|
|
185
|
+
/** Route guard */
|
|
186
|
+
guard?: ControlPanelConfig['guard'];
|
|
187
|
+
};
|
|
78
188
|
|
|
79
189
|
/**
|
|
80
190
|
* Frontend app configuration for the root path (/).
|
|
81
|
-
* If not provided,
|
|
191
|
+
* If not provided, shows a default landing page with system status.
|
|
82
192
|
*/
|
|
83
193
|
frontendApp?: {
|
|
84
194
|
/** Redirect to another URL */
|
|
@@ -91,16 +201,12 @@ export interface GatewayConfig {
|
|
|
91
201
|
heading?: string;
|
|
92
202
|
description?: string;
|
|
93
203
|
links?: Array<{ label: string; url: string }>;
|
|
204
|
+
branding?: {
|
|
205
|
+
primaryColor?: string;
|
|
206
|
+
};
|
|
94
207
|
};
|
|
95
208
|
};
|
|
96
209
|
|
|
97
|
-
/**
|
|
98
|
-
* API paths to proxy to the internal service.
|
|
99
|
-
* Defaults to ['/api/v1'] if not specified.
|
|
100
|
-
* The gateway always proxies /health to the internal service.
|
|
101
|
-
*/
|
|
102
|
-
proxyPaths?: string[];
|
|
103
|
-
|
|
104
210
|
/** Logger instance */
|
|
105
211
|
logger?: Logger;
|
|
106
212
|
|
|
@@ -108,49 +214,37 @@ export interface GatewayConfig {
|
|
|
108
214
|
logging?: LoggingConfig;
|
|
109
215
|
}
|
|
110
216
|
|
|
111
|
-
/**
|
|
112
|
-
* Service factory function type
|
|
113
|
-
* Called with the service port, should return an object with:
|
|
114
|
-
* - app: Express application (or compatible)
|
|
115
|
-
* - server: HTTP server (created by calling listen)
|
|
116
|
-
* - shutdown: Async function to gracefully shut down the service
|
|
117
|
-
*/
|
|
118
|
-
export interface ServiceFactory {
|
|
119
|
-
(port: number): Promise<{
|
|
120
|
-
app: Application;
|
|
121
|
-
server: Server;
|
|
122
|
-
shutdown: () => Promise<void>;
|
|
123
|
-
}>;
|
|
124
|
-
}
|
|
125
217
|
|
|
126
218
|
/**
|
|
127
219
|
* Gateway instance returned by createGateway
|
|
128
220
|
*/
|
|
129
221
|
export interface GatewayInstance {
|
|
130
|
-
/** The
|
|
131
|
-
|
|
222
|
+
/** The gateway Express app */
|
|
223
|
+
app: Application;
|
|
224
|
+
|
|
225
|
+
/** HTTP server */
|
|
226
|
+
server: Server | null;
|
|
132
227
|
|
|
133
|
-
/** The internal
|
|
134
|
-
|
|
135
|
-
app: Application;
|
|
136
|
-
server: Server;
|
|
137
|
-
shutdown: () => Promise<void>;
|
|
138
|
-
} | null;
|
|
228
|
+
/** The internal control panel (if enabled) */
|
|
229
|
+
controlPanel: ReturnType<typeof createControlPanel> | null;
|
|
139
230
|
|
|
140
|
-
/**
|
|
231
|
+
/** Mounted apps information */
|
|
232
|
+
mountedApps: Array<{
|
|
233
|
+
path: string;
|
|
234
|
+
type: 'proxy' | 'static';
|
|
235
|
+
target?: string;
|
|
236
|
+
}>;
|
|
237
|
+
|
|
238
|
+
/** Start the gateway */
|
|
141
239
|
start: () => Promise<void>;
|
|
142
240
|
|
|
143
241
|
/** Stop everything gracefully */
|
|
144
242
|
stop: () => Promise<void>;
|
|
145
243
|
|
|
146
244
|
/** Gateway port */
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
/** Service port */
|
|
150
|
-
servicePort: number;
|
|
245
|
+
port: number;
|
|
151
246
|
}
|
|
152
247
|
|
|
153
|
-
|
|
154
248
|
/**
|
|
155
249
|
* Generate landing page HTML for the frontend app
|
|
156
250
|
*/
|
|
@@ -160,7 +254,7 @@ function generateLandingPageHtml(
|
|
|
160
254
|
): string {
|
|
161
255
|
if (!config) return '';
|
|
162
256
|
|
|
163
|
-
const primaryColor = '#6366f1';
|
|
257
|
+
const primaryColor = config.branding?.primaryColor || '#6366f1';
|
|
164
258
|
|
|
165
259
|
const links = config.links || [
|
|
166
260
|
{ label: 'Control Panel', url: controlPanelPath },
|
|
@@ -256,14 +350,11 @@ function generateLandingPageHtml(
|
|
|
256
350
|
|
|
257
351
|
/**
|
|
258
352
|
* Generate default landing page HTML when no frontend app is configured
|
|
259
|
-
* Shows system status with animated background
|
|
260
353
|
*/
|
|
261
354
|
function generateDefaultLandingPageHtml(
|
|
262
355
|
productName: string,
|
|
263
356
|
controlPanelPath: string,
|
|
264
|
-
|
|
265
|
-
version: string,
|
|
266
|
-
logoUrl?: string
|
|
357
|
+
logoIconUrl?: string
|
|
267
358
|
): string {
|
|
268
359
|
return `<!DOCTYPE html>
|
|
269
360
|
<html lang="en">
|
|
@@ -297,81 +388,18 @@ function generateDefaultLandingPageHtml(
|
|
|
297
388
|
justify-content: center;
|
|
298
389
|
}
|
|
299
390
|
|
|
300
|
-
/* Animated gradient background */
|
|
301
391
|
.bg-gradient {
|
|
302
392
|
position: fixed;
|
|
303
|
-
top: 0;
|
|
304
|
-
left: 0;
|
|
305
|
-
right: 0;
|
|
306
|
-
bottom: 0;
|
|
393
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
307
394
|
background:
|
|
308
395
|
radial-gradient(ellipse at 20% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 50%),
|
|
309
|
-
radial-gradient(ellipse at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%)
|
|
310
|
-
radial-gradient(ellipse at 50% 50%, rgba(59, 130, 246, 0.05) 0%, transparent 70%);
|
|
396
|
+
radial-gradient(ellipse at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%);
|
|
311
397
|
animation: gradientShift 15s ease-in-out infinite;
|
|
312
398
|
}
|
|
313
399
|
|
|
314
400
|
@keyframes gradientShift {
|
|
315
|
-
0%, 100% {
|
|
316
|
-
|
|
317
|
-
opacity: 1;
|
|
318
|
-
}
|
|
319
|
-
50% {
|
|
320
|
-
background-position: 100% 0%, 0% 100%, 50% 50%;
|
|
321
|
-
opacity: 0.8;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/* Floating particles */
|
|
326
|
-
.particles {
|
|
327
|
-
position: fixed;
|
|
328
|
-
top: 0;
|
|
329
|
-
left: 0;
|
|
330
|
-
right: 0;
|
|
331
|
-
bottom: 0;
|
|
332
|
-
overflow: hidden;
|
|
333
|
-
pointer-events: none;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
.particle {
|
|
337
|
-
position: absolute;
|
|
338
|
-
width: 4px;
|
|
339
|
-
height: 4px;
|
|
340
|
-
background: var(--primary);
|
|
341
|
-
border-radius: 50%;
|
|
342
|
-
opacity: 0.3;
|
|
343
|
-
animation: float 20s infinite;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
.particle:nth-child(1) { left: 10%; animation-delay: 0s; animation-duration: 25s; }
|
|
347
|
-
.particle:nth-child(2) { left: 20%; animation-delay: 2s; animation-duration: 20s; }
|
|
348
|
-
.particle:nth-child(3) { left: 30%; animation-delay: 4s; animation-duration: 28s; }
|
|
349
|
-
.particle:nth-child(4) { left: 40%; animation-delay: 1s; animation-duration: 22s; }
|
|
350
|
-
.particle:nth-child(5) { left: 50%; animation-delay: 3s; animation-duration: 24s; }
|
|
351
|
-
.particle:nth-child(6) { left: 60%; animation-delay: 5s; animation-duration: 26s; }
|
|
352
|
-
.particle:nth-child(7) { left: 70%; animation-delay: 2s; animation-duration: 21s; }
|
|
353
|
-
.particle:nth-child(8) { left: 80%; animation-delay: 4s; animation-duration: 23s; }
|
|
354
|
-
.particle:nth-child(9) { left: 90%; animation-delay: 1s; animation-duration: 27s; }
|
|
355
|
-
|
|
356
|
-
@keyframes float {
|
|
357
|
-
0% { transform: translateY(100vh) scale(0); opacity: 0; }
|
|
358
|
-
10% { opacity: 0.3; }
|
|
359
|
-
90% { opacity: 0.3; }
|
|
360
|
-
100% { transform: translateY(-100vh) scale(1); opacity: 0; }
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/* Grid pattern overlay */
|
|
364
|
-
.grid-overlay {
|
|
365
|
-
position: fixed;
|
|
366
|
-
top: 0;
|
|
367
|
-
left: 0;
|
|
368
|
-
right: 0;
|
|
369
|
-
bottom: 0;
|
|
370
|
-
background-image:
|
|
371
|
-
linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px),
|
|
372
|
-
linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px);
|
|
373
|
-
background-size: 60px 60px;
|
|
374
|
-
pointer-events: none;
|
|
401
|
+
0%, 100% { opacity: 1; }
|
|
402
|
+
50% { opacity: 0.8; }
|
|
375
403
|
}
|
|
376
404
|
|
|
377
405
|
.container {
|
|
@@ -398,10 +426,6 @@ function generateDefaultLandingPageHtml(
|
|
|
398
426
|
box-shadow: 0 20px 40px var(--primary-glow);
|
|
399
427
|
}
|
|
400
428
|
|
|
401
|
-
.logo.custom {
|
|
402
|
-
filter: drop-shadow(0 20px 40px var(--primary-glow));
|
|
403
|
-
}
|
|
404
|
-
|
|
405
429
|
@keyframes logoFloat {
|
|
406
430
|
0%, 100% { transform: translateY(0); }
|
|
407
431
|
50% { transform: translateY(-10px); }
|
|
@@ -414,8 +438,8 @@ function generateDefaultLandingPageHtml(
|
|
|
414
438
|
}
|
|
415
439
|
|
|
416
440
|
.logo img {
|
|
417
|
-
width:
|
|
418
|
-
height:
|
|
441
|
+
width: 64px;
|
|
442
|
+
height: 64px;
|
|
419
443
|
object-fit: contain;
|
|
420
444
|
}
|
|
421
445
|
|
|
@@ -450,17 +474,6 @@ function generateDefaultLandingPageHtml(
|
|
|
450
474
|
animation: pulse 2s ease-in-out infinite;
|
|
451
475
|
}
|
|
452
476
|
|
|
453
|
-
.status-dot.degraded {
|
|
454
|
-
background: var(--warning);
|
|
455
|
-
box-shadow: 0 0 10px var(--warning);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
.status-dot.unhealthy {
|
|
459
|
-
background: var(--error);
|
|
460
|
-
box-shadow: 0 0 10px var(--error);
|
|
461
|
-
animation: none;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
477
|
@keyframes pulse {
|
|
465
478
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
466
479
|
50% { opacity: 0.7; transform: scale(1.1); }
|
|
@@ -469,7 +482,6 @@ function generateDefaultLandingPageHtml(
|
|
|
469
482
|
.status-text {
|
|
470
483
|
font-size: 0.95rem;
|
|
471
484
|
font-weight: 500;
|
|
472
|
-
color: var(--text-primary);
|
|
473
485
|
}
|
|
474
486
|
|
|
475
487
|
.description {
|
|
@@ -522,54 +534,28 @@ function generateDefaultLandingPageHtml(
|
|
|
522
534
|
text-decoration: none;
|
|
523
535
|
font-weight: 500;
|
|
524
536
|
}
|
|
525
|
-
|
|
526
|
-
.footer a:hover {
|
|
527
|
-
text-decoration: underline;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
/* Loading state */
|
|
531
|
-
.loading .status-dot {
|
|
532
|
-
background: var(--text-secondary);
|
|
533
|
-
box-shadow: none;
|
|
534
|
-
animation: none;
|
|
535
|
-
}
|
|
536
537
|
</style>
|
|
537
538
|
</head>
|
|
538
539
|
<body>
|
|
539
540
|
<div class="bg-gradient"></div>
|
|
540
|
-
<div class="particles">
|
|
541
|
-
<div class="particle"></div>
|
|
542
|
-
<div class="particle"></div>
|
|
543
|
-
<div class="particle"></div>
|
|
544
|
-
<div class="particle"></div>
|
|
545
|
-
<div class="particle"></div>
|
|
546
|
-
<div class="particle"></div>
|
|
547
|
-
<div class="particle"></div>
|
|
548
|
-
<div class="particle"></div>
|
|
549
|
-
<div class="particle"></div>
|
|
550
|
-
</div>
|
|
551
|
-
<div class="grid-overlay"></div>
|
|
552
541
|
|
|
553
542
|
<div class="container">
|
|
554
|
-
${
|
|
555
|
-
? `<div class="logo
|
|
543
|
+
${logoIconUrl
|
|
544
|
+
? `<div class="logo"><img src="${logoIconUrl}" alt="${productName} logo"></div>`
|
|
556
545
|
: `<div class="logo default">
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
546
|
+
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
547
|
+
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
|
548
|
+
</svg>
|
|
549
|
+
</div>`
|
|
550
|
+
}
|
|
561
551
|
|
|
562
552
|
<h1>${productName}</h1>
|
|
563
553
|
|
|
564
|
-
<div class="status-badge
|
|
565
|
-
<div class="status-dot"
|
|
566
|
-
<span class="status-text"
|
|
554
|
+
<div class="status-badge">
|
|
555
|
+
<div class="status-dot"></div>
|
|
556
|
+
<span class="status-text">Gateway Online</span>
|
|
567
557
|
</div>
|
|
568
558
|
|
|
569
|
-
<p class="description" id="description">
|
|
570
|
-
Enterprise-grade service powered by QwickApps
|
|
571
|
-
</p>
|
|
572
|
-
|
|
573
559
|
<div class="links">
|
|
574
560
|
<a href="${controlPanelPath}" class="link">
|
|
575
561
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
@@ -584,226 +570,669 @@ function generateDefaultLandingPageHtml(
|
|
|
584
570
|
</div>
|
|
585
571
|
|
|
586
572
|
<div class="footer">
|
|
587
|
-
Powered by <a href="https://qwickapps.com" target="_blank">QwickApps Server</a> - <a href="https://github.com/qwickapps/server" target="_blank">Version ${
|
|
573
|
+
Enterprise Services Powered by <a href="https://qwickapps.com" target="_blank">QwickApps Server</a> - <a href="https://github.com/qwickapps/server" target="_blank">Version ${QWICKAPPS_SERVER_VERSION}</a>
|
|
588
574
|
</div>
|
|
575
|
+
</body>
|
|
576
|
+
</html>`;
|
|
577
|
+
}
|
|
589
578
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
text.textContent = 'Unable to connect';
|
|
620
|
-
desc.textContent = 'Could not reach the service. Please try again later.';
|
|
621
|
-
}
|
|
579
|
+
/**
|
|
580
|
+
* Shared CSS styles for status pages (maintenance, service unavailable)
|
|
581
|
+
*/
|
|
582
|
+
const statusPageStyles = `
|
|
583
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
584
|
+
|
|
585
|
+
:root {
|
|
586
|
+
--bg-dark: #0a0a0f;
|
|
587
|
+
--bg-card: rgba(255, 255, 255, 0.03);
|
|
588
|
+
--text-primary: #f1f5f9;
|
|
589
|
+
--text-secondary: #94a3b8;
|
|
590
|
+
--border-color: rgba(255, 255, 255, 0.08);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
body {
|
|
594
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
|
595
|
+
background: var(--bg-dark);
|
|
596
|
+
color: var(--text-primary);
|
|
597
|
+
min-height: 100vh;
|
|
598
|
+
display: flex;
|
|
599
|
+
align-items: center;
|
|
600
|
+
justify-content: center;
|
|
601
|
+
overflow: hidden;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.bg-gradient {
|
|
605
|
+
position: fixed;
|
|
606
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
607
|
+
animation: gradientShift 15s ease-in-out infinite;
|
|
622
608
|
}
|
|
623
609
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
610
|
+
@keyframes gradientShift {
|
|
611
|
+
0%, 100% { opacity: 1; }
|
|
612
|
+
50% { opacity: 0.8; }
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.container {
|
|
616
|
+
position: relative;
|
|
617
|
+
z-index: 10;
|
|
618
|
+
text-align: center;
|
|
619
|
+
max-width: 480px;
|
|
620
|
+
padding: 3rem 2rem;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
.icon-wrapper {
|
|
624
|
+
width: 100px;
|
|
625
|
+
height: 100px;
|
|
626
|
+
margin: 0 auto 2rem;
|
|
627
|
+
border-radius: 50%;
|
|
628
|
+
display: flex;
|
|
629
|
+
align-items: center;
|
|
630
|
+
justify-content: center;
|
|
631
|
+
animation: iconPulse 3s ease-in-out infinite;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
@keyframes iconPulse {
|
|
635
|
+
0%, 100% { transform: scale(1); }
|
|
636
|
+
50% { transform: scale(1.05); }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
.icon-wrapper svg {
|
|
640
|
+
width: 48px;
|
|
641
|
+
height: 48px;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
h1 {
|
|
645
|
+
font-size: 2rem;
|
|
646
|
+
font-weight: 700;
|
|
647
|
+
margin-bottom: 0.75rem;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.subtitle {
|
|
651
|
+
color: var(--text-secondary);
|
|
652
|
+
font-size: 1.05rem;
|
|
653
|
+
line-height: 1.6;
|
|
654
|
+
margin-bottom: 2rem;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
.status-card {
|
|
658
|
+
background: var(--bg-card);
|
|
659
|
+
border: 1px solid var(--border-color);
|
|
660
|
+
border-radius: 16px;
|
|
661
|
+
padding: 1.5rem;
|
|
662
|
+
margin-bottom: 2rem;
|
|
663
|
+
backdrop-filter: blur(10px);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.status-row {
|
|
667
|
+
display: flex;
|
|
668
|
+
align-items: center;
|
|
669
|
+
justify-content: center;
|
|
670
|
+
gap: 0.75rem;
|
|
671
|
+
color: var(--text-secondary);
|
|
672
|
+
font-size: 0.95rem;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.status-row svg {
|
|
676
|
+
width: 20px;
|
|
677
|
+
height: 20px;
|
|
678
|
+
flex-shrink: 0;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.eta-badge {
|
|
682
|
+
display: inline-flex;
|
|
683
|
+
align-items: center;
|
|
684
|
+
gap: 0.5rem;
|
|
685
|
+
padding: 0.75rem 1.25rem;
|
|
686
|
+
background: var(--bg-card);
|
|
687
|
+
border: 1px solid var(--border-color);
|
|
688
|
+
border-radius: 100px;
|
|
689
|
+
font-size: 0.9rem;
|
|
690
|
+
color: var(--text-secondary);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.actions {
|
|
694
|
+
display: flex;
|
|
695
|
+
flex-wrap: wrap;
|
|
696
|
+
gap: 1rem;
|
|
697
|
+
justify-content: center;
|
|
698
|
+
margin-top: 2rem;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.btn {
|
|
702
|
+
display: inline-flex;
|
|
703
|
+
align-items: center;
|
|
704
|
+
gap: 0.5rem;
|
|
705
|
+
padding: 0.875rem 1.5rem;
|
|
706
|
+
border-radius: 12px;
|
|
707
|
+
font-weight: 500;
|
|
708
|
+
font-size: 0.95rem;
|
|
709
|
+
text-decoration: none;
|
|
710
|
+
transition: all 0.3s ease;
|
|
711
|
+
cursor: pointer;
|
|
712
|
+
border: none;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.btn-primary {
|
|
716
|
+
background: var(--accent-color);
|
|
717
|
+
color: white;
|
|
718
|
+
box-shadow: 0 4px 15px var(--accent-glow);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.btn-primary:hover {
|
|
722
|
+
transform: translateY(-2px);
|
|
723
|
+
box-shadow: 0 8px 25px var(--accent-glow);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
.btn-secondary {
|
|
727
|
+
background: var(--bg-card);
|
|
728
|
+
border: 1px solid var(--border-color);
|
|
729
|
+
color: var(--text-primary);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
.btn-secondary:hover {
|
|
733
|
+
background: rgba(255, 255, 255, 0.08);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
.footer {
|
|
737
|
+
position: fixed;
|
|
738
|
+
bottom: 1.5rem;
|
|
739
|
+
left: 0;
|
|
740
|
+
right: 0;
|
|
741
|
+
text-align: center;
|
|
742
|
+
color: var(--text-secondary);
|
|
743
|
+
font-size: 0.85rem;
|
|
744
|
+
z-index: 10;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
@media (max-width: 480px) {
|
|
748
|
+
.container { padding: 2rem 1.5rem; }
|
|
749
|
+
h1 { font-size: 1.5rem; }
|
|
750
|
+
.icon-wrapper { width: 80px; height: 80px; }
|
|
751
|
+
.icon-wrapper svg { width: 40px; height: 40px; }
|
|
752
|
+
}
|
|
753
|
+
`;
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Generate a maintenance page HTML
|
|
757
|
+
*/
|
|
758
|
+
function generateMaintenancePageHtml(
|
|
759
|
+
appName: string,
|
|
760
|
+
config: MaintenanceConfig,
|
|
761
|
+
productName: string
|
|
762
|
+
): string {
|
|
763
|
+
const title = config.title || 'Under Maintenance';
|
|
764
|
+
const message = config.message || `${appName} is currently undergoing scheduled maintenance.`;
|
|
765
|
+
|
|
766
|
+
let etaHtml = '';
|
|
767
|
+
if (config.expectedBackAt) {
|
|
768
|
+
const eta = config.expectedBackAt;
|
|
769
|
+
if (eta === 'soon') {
|
|
770
|
+
etaHtml = `
|
|
771
|
+
<div class="eta-badge">
|
|
772
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
773
|
+
<circle cx="12" cy="12" r="10"/>
|
|
774
|
+
<path d="M12 6v6l4 2"/>
|
|
775
|
+
</svg>
|
|
776
|
+
Back online soon
|
|
777
|
+
</div>`;
|
|
778
|
+
} else if (eta.includes('hour') || eta.includes('minute')) {
|
|
779
|
+
etaHtml = `
|
|
780
|
+
<div class="eta-badge">
|
|
781
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
782
|
+
<circle cx="12" cy="12" r="10"/>
|
|
783
|
+
<path d="M12 6v6l4 2"/>
|
|
784
|
+
</svg>
|
|
785
|
+
Expected back in ${eta}
|
|
786
|
+
</div>`;
|
|
787
|
+
} else {
|
|
788
|
+
// ISO date string
|
|
789
|
+
etaHtml = `
|
|
790
|
+
<div class="eta-badge">
|
|
791
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
792
|
+
<circle cx="12" cy="12" r="10"/>
|
|
793
|
+
<path d="M12 6v6l4 2"/>
|
|
794
|
+
</svg>
|
|
795
|
+
<span id="eta-countdown">Calculating...</span>
|
|
796
|
+
</div>
|
|
797
|
+
<script>
|
|
798
|
+
(function() {
|
|
799
|
+
const target = new Date('${eta}');
|
|
800
|
+
const el = document.getElementById('eta-countdown');
|
|
801
|
+
function update() {
|
|
802
|
+
const now = new Date();
|
|
803
|
+
const diff = target - now;
|
|
804
|
+
if (diff <= 0) {
|
|
805
|
+
el.textContent = 'Should be back now';
|
|
806
|
+
setTimeout(() => location.reload(), 5000);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const hours = Math.floor(diff / 3600000);
|
|
810
|
+
const mins = Math.floor((diff % 3600000) / 60000);
|
|
811
|
+
if (hours > 0) {
|
|
812
|
+
el.textContent = 'Back in ' + hours + 'h ' + mins + 'm';
|
|
813
|
+
} else {
|
|
814
|
+
el.textContent = 'Back in ' + mins + ' minutes';
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
update();
|
|
818
|
+
setInterval(update, 60000);
|
|
819
|
+
})();
|
|
820
|
+
</script>`;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const contactHtml = config.contactUrl
|
|
825
|
+
? `<a href="${config.contactUrl}" class="btn btn-secondary">
|
|
826
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
|
827
|
+
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
|
828
|
+
<polyline points="22,6 12,13 2,6"/>
|
|
829
|
+
</svg>
|
|
830
|
+
Contact Support
|
|
831
|
+
</a>`
|
|
832
|
+
: '';
|
|
833
|
+
|
|
834
|
+
return `<!DOCTYPE html>
|
|
835
|
+
<html lang="en">
|
|
836
|
+
<head>
|
|
837
|
+
<meta charset="UTF-8">
|
|
838
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
839
|
+
<title>${title} - ${productName}</title>
|
|
840
|
+
<style>
|
|
841
|
+
${statusPageStyles}
|
|
842
|
+
:root {
|
|
843
|
+
--accent-color: #f59e0b;
|
|
844
|
+
--accent-glow: rgba(245, 158, 11, 0.3);
|
|
845
|
+
}
|
|
846
|
+
.bg-gradient {
|
|
847
|
+
background:
|
|
848
|
+
radial-gradient(ellipse at 30% 30%, rgba(245, 158, 11, 0.12) 0%, transparent 50%),
|
|
849
|
+
radial-gradient(ellipse at 70% 70%, rgba(234, 179, 8, 0.08) 0%, transparent 50%);
|
|
850
|
+
}
|
|
851
|
+
.icon-wrapper {
|
|
852
|
+
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
|
853
|
+
box-shadow: 0 20px 40px rgba(245, 158, 11, 0.3);
|
|
854
|
+
}
|
|
855
|
+
</style>
|
|
856
|
+
</head>
|
|
857
|
+
<body>
|
|
858
|
+
<div class="bg-gradient"></div>
|
|
859
|
+
|
|
860
|
+
<div class="container">
|
|
861
|
+
<div class="icon-wrapper">
|
|
862
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
|
863
|
+
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
|
864
|
+
</svg>
|
|
865
|
+
</div>
|
|
866
|
+
|
|
867
|
+
<h1>${title}</h1>
|
|
868
|
+
<p class="subtitle">${message}</p>
|
|
869
|
+
|
|
870
|
+
<div class="status-card">
|
|
871
|
+
<div class="status-row">
|
|
872
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
873
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
|
874
|
+
</svg>
|
|
875
|
+
<span>We're performing upgrades to improve your experience</span>
|
|
876
|
+
</div>
|
|
877
|
+
</div>
|
|
878
|
+
|
|
879
|
+
${etaHtml}
|
|
880
|
+
|
|
881
|
+
<div class="actions">
|
|
882
|
+
<button onclick="location.reload()" class="btn btn-primary">
|
|
883
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
|
884
|
+
<path d="M23 4v6h-6"/>
|
|
885
|
+
<path d="M1 20v-6h6"/>
|
|
886
|
+
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
|
887
|
+
</svg>
|
|
888
|
+
Check Again
|
|
889
|
+
</button>
|
|
890
|
+
${contactHtml}
|
|
891
|
+
</div>
|
|
892
|
+
</div>
|
|
893
|
+
|
|
894
|
+
<div class="footer">
|
|
895
|
+
${productName}
|
|
896
|
+
</div>
|
|
897
|
+
</body>
|
|
898
|
+
</html>`;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Generate a service unavailable page HTML (when proxy fails)
|
|
903
|
+
*/
|
|
904
|
+
function generateServiceUnavailablePageHtml(
|
|
905
|
+
appName: string,
|
|
906
|
+
path: string,
|
|
907
|
+
config: FallbackConfig | undefined,
|
|
908
|
+
productName: string
|
|
909
|
+
): string {
|
|
910
|
+
const title = config?.title || 'Service Unavailable';
|
|
911
|
+
const message = config?.message || `${appName} is temporarily unavailable. Our team has been notified.`;
|
|
912
|
+
const showRetry = config?.showRetry !== false;
|
|
913
|
+
const autoRefresh = config?.autoRefresh ?? 30;
|
|
914
|
+
|
|
915
|
+
const autoRefreshScript = autoRefresh > 0
|
|
916
|
+
? `<script>
|
|
917
|
+
let countdown = ${autoRefresh};
|
|
918
|
+
const el = document.getElementById('refresh-countdown');
|
|
919
|
+
setInterval(() => {
|
|
920
|
+
countdown--;
|
|
921
|
+
if (countdown <= 0) location.reload();
|
|
922
|
+
el.textContent = countdown;
|
|
923
|
+
}, 1000);
|
|
924
|
+
</script>`
|
|
925
|
+
: '';
|
|
926
|
+
|
|
927
|
+
const autoRefreshHtml = autoRefresh > 0
|
|
928
|
+
? `<div class="status-row" style="margin-top: 1rem;">
|
|
929
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
930
|
+
<circle cx="12" cy="12" r="10"/>
|
|
931
|
+
<path d="M12 6v6l4 2"/>
|
|
932
|
+
</svg>
|
|
933
|
+
<span>Auto-refreshing in <strong id="refresh-countdown">${autoRefresh}</strong>s</span>
|
|
934
|
+
</div>`
|
|
935
|
+
: '';
|
|
936
|
+
|
|
937
|
+
const retryButtonHtml = showRetry
|
|
938
|
+
? `<button onclick="location.reload()" class="btn btn-primary">
|
|
939
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
|
940
|
+
<path d="M23 4v6h-6"/>
|
|
941
|
+
<path d="M1 20v-6h6"/>
|
|
942
|
+
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
|
943
|
+
</svg>
|
|
944
|
+
Try Again
|
|
945
|
+
</button>`
|
|
946
|
+
: '';
|
|
947
|
+
|
|
948
|
+
return `<!DOCTYPE html>
|
|
949
|
+
<html lang="en">
|
|
950
|
+
<head>
|
|
951
|
+
<meta charset="UTF-8">
|
|
952
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
953
|
+
<title>${title} - ${productName}</title>
|
|
954
|
+
<style>
|
|
955
|
+
${statusPageStyles}
|
|
956
|
+
:root {
|
|
957
|
+
--accent-color: #ef4444;
|
|
958
|
+
--accent-glow: rgba(239, 68, 68, 0.3);
|
|
959
|
+
}
|
|
960
|
+
.bg-gradient {
|
|
961
|
+
background:
|
|
962
|
+
radial-gradient(ellipse at 30% 30%, rgba(239, 68, 68, 0.1) 0%, transparent 50%),
|
|
963
|
+
radial-gradient(ellipse at 70% 70%, rgba(220, 38, 38, 0.08) 0%, transparent 50%);
|
|
964
|
+
}
|
|
965
|
+
.icon-wrapper {
|
|
966
|
+
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
|
967
|
+
box-shadow: 0 20px 40px rgba(239, 68, 68, 0.3);
|
|
968
|
+
}
|
|
969
|
+
</style>
|
|
970
|
+
</head>
|
|
971
|
+
<body>
|
|
972
|
+
<div class="bg-gradient"></div>
|
|
973
|
+
|
|
974
|
+
<div class="container">
|
|
975
|
+
<div class="icon-wrapper">
|
|
976
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
|
977
|
+
<circle cx="12" cy="12" r="10"/>
|
|
978
|
+
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
979
|
+
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
980
|
+
</svg>
|
|
981
|
+
</div>
|
|
982
|
+
|
|
983
|
+
<h1>${title}</h1>
|
|
984
|
+
<p class="subtitle">${message}</p>
|
|
985
|
+
|
|
986
|
+
<div class="status-card">
|
|
987
|
+
<div class="status-row">
|
|
988
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
989
|
+
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
|
|
990
|
+
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
|
|
991
|
+
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
|
992
|
+
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
|
993
|
+
</svg>
|
|
994
|
+
<span>The service at <code style="background: rgba(255,255,255,0.1); padding: 0.2rem 0.4rem; border-radius: 4px;">${path}</code> is not responding</span>
|
|
995
|
+
</div>
|
|
996
|
+
${autoRefreshHtml}
|
|
997
|
+
</div>
|
|
998
|
+
|
|
999
|
+
<div class="actions">
|
|
1000
|
+
${retryButtonHtml}
|
|
1001
|
+
<a href="/" class="btn btn-secondary">
|
|
1002
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
|
1003
|
+
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
|
1004
|
+
<polyline points="9,22 9,12 15,12 15,22"/>
|
|
1005
|
+
</svg>
|
|
1006
|
+
Go Home
|
|
1007
|
+
</a>
|
|
1008
|
+
</div>
|
|
1009
|
+
</div>
|
|
1010
|
+
|
|
1011
|
+
<div class="footer">
|
|
1012
|
+
${productName}
|
|
1013
|
+
</div>
|
|
1014
|
+
${autoRefreshScript}
|
|
628
1015
|
</body>
|
|
629
1016
|
</html>`;
|
|
630
1017
|
}
|
|
631
1018
|
|
|
632
1019
|
/**
|
|
633
|
-
* Create a gateway that proxies to
|
|
1020
|
+
* Create a gateway that proxies to multiple internal services
|
|
634
1021
|
*
|
|
635
1022
|
* @param config - Gateway configuration
|
|
636
|
-
* @param serviceFactory - Factory function to create the internal service
|
|
637
1023
|
* @returns Gateway instance
|
|
638
1024
|
*
|
|
639
1025
|
* @example
|
|
640
1026
|
* ```typescript
|
|
641
1027
|
* import { createGateway } from '@qwickapps/server';
|
|
642
1028
|
*
|
|
643
|
-
* const gateway = createGateway(
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
1029
|
+
* const gateway = createGateway({
|
|
1030
|
+
* productName: 'My Product',
|
|
1031
|
+
* port: 3000,
|
|
1032
|
+
* controlPanel: {
|
|
1033
|
+
* path: '/cpanel',
|
|
1034
|
+
* port: 3001,
|
|
1035
|
+
* plugins: [...],
|
|
648
1036
|
* },
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
* server,
|
|
655
|
-
* shutdown: async () => { server.close(); },
|
|
656
|
-
* };
|
|
657
|
-
* }
|
|
658
|
-
* );
|
|
1037
|
+
* apps: [
|
|
1038
|
+
* { path: '/api', source: { type: 'proxy', target: 'http://localhost:3002' } },
|
|
1039
|
+
* { path: '/docs', source: { type: 'static', directory: './docs' } },
|
|
1040
|
+
* ],
|
|
1041
|
+
* });
|
|
659
1042
|
*
|
|
660
1043
|
* await gateway.start();
|
|
661
1044
|
* ```
|
|
662
1045
|
*/
|
|
663
|
-
export function createGateway(
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
): GatewayInstance {
|
|
667
|
-
// Initialize logging subsystem first
|
|
668
|
-
const loggingSubsystem = initializeLogging({
|
|
1046
|
+
export function createGateway(config: GatewayConfig): GatewayInstance {
|
|
1047
|
+
// Initialize logging (side effect - subsystem is initialized globally)
|
|
1048
|
+
initializeLogging({
|
|
669
1049
|
namespace: config.productName,
|
|
670
1050
|
...config.logging,
|
|
671
1051
|
});
|
|
672
1052
|
|
|
673
|
-
// Use provided logger or get one from the logging subsystem
|
|
674
1053
|
const logger = config.logger || getControlPanelLogger('Gateway');
|
|
675
1054
|
|
|
676
|
-
|
|
677
|
-
const
|
|
1055
|
+
// Port configuration - new scheme: 3000 gateway, 3001 cpanel, 3002+ apps
|
|
1056
|
+
const gatewayPort = config.port || parseInt(process.env.GATEWAY_PORT || process.env.PORT || '3000', 10);
|
|
678
1057
|
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
1058
|
+
const version = config.version || process.env.npm_package_version || '1.0.0';
|
|
679
1059
|
|
|
680
|
-
// Control panel
|
|
681
|
-
const
|
|
1060
|
+
// Control panel configuration
|
|
1061
|
+
const cpConfig = config.controlPanel ?? { enabled: true };
|
|
1062
|
+
const cpEnabled = cpConfig.enabled !== false;
|
|
1063
|
+
const cpPath = cpConfig.path || '/cpanel';
|
|
1064
|
+
const cpPort = cpConfig.port || 3001;
|
|
682
1065
|
|
|
683
|
-
//
|
|
684
|
-
const
|
|
1066
|
+
// Create gateway Express app
|
|
1067
|
+
const app = express();
|
|
1068
|
+
let server: Server | null = null;
|
|
1069
|
+
let controlPanelInstance: ReturnType<typeof createControlPanel> | null = null;
|
|
1070
|
+
const mountedApps: GatewayInstance['mountedApps'] = [];
|
|
685
1071
|
|
|
686
|
-
|
|
687
|
-
|
|
1072
|
+
/**
|
|
1073
|
+
* Setup proxy middleware for an app
|
|
1074
|
+
*/
|
|
1075
|
+
const setupProxyApp = (appConfig: MountedAppConfig, httpServer: Server) => {
|
|
1076
|
+
const { path, source, stripPrefix = true, name, maintenance, fallback } = appConfig;
|
|
688
1077
|
|
|
689
|
-
|
|
690
|
-
const version = config.version || process.env.npm_package_version || '1.0.0';
|
|
1078
|
+
if (source.type !== 'proxy') return;
|
|
691
1079
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
// Create control panel
|
|
695
|
-
const controlPanel = createControlPanel({
|
|
696
|
-
config: {
|
|
697
|
-
productName: config.productName,
|
|
698
|
-
port: gatewayPort,
|
|
699
|
-
version,
|
|
700
|
-
branding: config.branding,
|
|
701
|
-
cors: config.corsOrigins ? { origins: config.corsOrigins } : undefined,
|
|
702
|
-
// Skip body parsing for proxied paths
|
|
703
|
-
skipBodyParserPaths: [...proxyPaths, '/health'],
|
|
704
|
-
// Mount path for control panel
|
|
705
|
-
mountPath: controlPanelPath,
|
|
706
|
-
// Route guard
|
|
707
|
-
guard: guardConfig,
|
|
708
|
-
// Custom UI path
|
|
709
|
-
customUiPath: config.customUiPath,
|
|
710
|
-
links: config.links,
|
|
711
|
-
},
|
|
712
|
-
plugins: config.plugins || [],
|
|
713
|
-
logger,
|
|
714
|
-
});
|
|
1080
|
+
const appName = name || path.replace(/^\//, '') || 'Service';
|
|
1081
|
+
logger.debug(`Setting up proxy: ${path} -> ${source.target}`);
|
|
715
1082
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
if (res && 'writeHead' in res && !res.headersSent) {
|
|
730
|
-
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
731
|
-
res.end(
|
|
732
|
-
JSON.stringify({
|
|
733
|
-
error: 'Service Unavailable',
|
|
734
|
-
message: 'The service is currently unavailable. Please try again later.',
|
|
735
|
-
details: nodeEnv === 'development' ? err.message : undefined,
|
|
736
|
-
})
|
|
737
|
-
);
|
|
738
|
-
}
|
|
739
|
-
},
|
|
740
|
-
},
|
|
741
|
-
};
|
|
742
|
-
controlPanel.app.use(createProxyMiddleware(proxyOptions));
|
|
1083
|
+
// Maintenance mode middleware - intercepts all requests when enabled
|
|
1084
|
+
if (maintenance?.enabled) {
|
|
1085
|
+
logger.info(`Maintenance mode enabled for ${path}`);
|
|
1086
|
+
app.use(path, (req, res, next) => {
|
|
1087
|
+
// Check bypass paths
|
|
1088
|
+
if (maintenance.bypassPaths?.some(bp => req.path.startsWith(bp))) {
|
|
1089
|
+
return next();
|
|
1090
|
+
}
|
|
1091
|
+
const html = generateMaintenancePageHtml(appName, maintenance, config.productName);
|
|
1092
|
+
res.status(503).type('html').send(html);
|
|
1093
|
+
});
|
|
1094
|
+
mountedApps.push({ path, type: 'proxy', target: source.target });
|
|
1095
|
+
return; // Don't setup proxy when in maintenance mode
|
|
743
1096
|
}
|
|
744
1097
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1098
|
+
const proxyOptions: Options = {
|
|
1099
|
+
target: source.target,
|
|
1100
|
+
changeOrigin: true,
|
|
1101
|
+
ws: source.ws ?? false,
|
|
1102
|
+
pathRewrite: stripPrefix ? { [`^${path}`]: '' } : undefined,
|
|
750
1103
|
on: {
|
|
751
|
-
|
|
1104
|
+
proxyReq: (proxyReq) => {
|
|
1105
|
+
// Add X-Forwarded headers so app knows its mounted path
|
|
1106
|
+
proxyReq.setHeader('X-Forwarded-Prefix', path);
|
|
1107
|
+
},
|
|
1108
|
+
error: (err: Error, req: IncomingMessage, res: ServerResponse | Socket) => {
|
|
1109
|
+
logger.error(`Proxy error for ${path}`, { error: err.message });
|
|
1110
|
+
|
|
752
1111
|
if (res && 'writeHead' in res && !res.headersSent) {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
})
|
|
760
|
-
|
|
1112
|
+
// Check if this looks like an API request (Accept: application/json or /api/ path)
|
|
1113
|
+
const acceptHeader = req.headers['accept'] || '';
|
|
1114
|
+
const isApiRequest = acceptHeader.includes('application/json') || req.url?.includes('/api/');
|
|
1115
|
+
|
|
1116
|
+
if (isApiRequest) {
|
|
1117
|
+
// Return JSON error for API requests
|
|
1118
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1119
|
+
res.end(JSON.stringify({
|
|
1120
|
+
error: 'Service Unavailable',
|
|
1121
|
+
message: `The service at ${path} is currently unavailable.`,
|
|
1122
|
+
details: nodeEnv === 'development' ? err.message : undefined,
|
|
1123
|
+
}));
|
|
1124
|
+
} else {
|
|
1125
|
+
// Return beautiful HTML page for browser requests
|
|
1126
|
+
const html = generateServiceUnavailablePageHtml(appName, path, fallback, config.productName);
|
|
1127
|
+
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
1128
|
+
res.end(html);
|
|
1129
|
+
}
|
|
761
1130
|
}
|
|
762
1131
|
},
|
|
763
1132
|
},
|
|
764
1133
|
};
|
|
765
|
-
|
|
1134
|
+
|
|
1135
|
+
const proxy = createProxyMiddleware(proxyOptions);
|
|
1136
|
+
|
|
1137
|
+
// Mount proxy
|
|
1138
|
+
app.use(path, proxy);
|
|
1139
|
+
|
|
1140
|
+
// WebSocket upgrade handling
|
|
1141
|
+
if (source.ws && httpServer) {
|
|
1142
|
+
httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
1143
|
+
if (req.url?.startsWith(path)) {
|
|
1144
|
+
proxy.upgrade?.(req, socket as Socket, head);
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
mountedApps.push({ path, type: 'proxy', target: source.target });
|
|
766
1150
|
};
|
|
767
1151
|
|
|
768
|
-
|
|
769
|
-
|
|
1152
|
+
/**
|
|
1153
|
+
* Setup static file serving for an app
|
|
1154
|
+
*/
|
|
1155
|
+
const setupStaticApp = (appConfig: MountedAppConfig) => {
|
|
1156
|
+
const { path, source } = appConfig;
|
|
1157
|
+
|
|
1158
|
+
if (source.type !== 'static') return;
|
|
1159
|
+
|
|
1160
|
+
logger.debug(`Setting up static: ${path} -> ${source.directory}`);
|
|
1161
|
+
|
|
1162
|
+
if (!existsSync(source.directory)) {
|
|
1163
|
+
logger.warn(`Static directory not found: ${source.directory}`);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Serve static files
|
|
1168
|
+
app.use(path, express.static(source.directory, { index: false }));
|
|
1169
|
+
|
|
1170
|
+
// SPA fallback
|
|
1171
|
+
if (source.spa) {
|
|
1172
|
+
const indexPath = join(source.directory, 'index.html');
|
|
1173
|
+
|
|
1174
|
+
// Read and cache index.html with path rewriting
|
|
1175
|
+
let cachedHtml: string | null = null;
|
|
1176
|
+
const getIndexHtml = (): string => {
|
|
1177
|
+
if (cachedHtml) return cachedHtml;
|
|
1178
|
+
let html = readFileSync(indexPath, 'utf-8');
|
|
1179
|
+
// Rewrite asset paths for non-root mount
|
|
1180
|
+
if (path !== '/') {
|
|
1181
|
+
html = html.replace(/src="\/assets\//g, `src="${path}/assets/`);
|
|
1182
|
+
html = html.replace(/href="\/assets\//g, `href="${path}/assets/`);
|
|
1183
|
+
}
|
|
1184
|
+
cachedHtml = html;
|
|
1185
|
+
return html;
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
app.get(`${path}/*`, (_req, res) => {
|
|
1189
|
+
res.type('html').send(getIndexHtml());
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
if (path !== '/') {
|
|
1193
|
+
app.get(path, (_req, res) => {
|
|
1194
|
+
res.type('html').send(getIndexHtml());
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
770
1198
|
|
|
771
|
-
|
|
1199
|
+
mountedApps.push({ path, type: 'static' });
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Setup frontend app at root path
|
|
1204
|
+
*/
|
|
772
1205
|
const setupFrontendApp = () => {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1206
|
+
const { frontendApp, logoIconUrl } = config;
|
|
1207
|
+
|
|
1208
|
+
// Default landing page
|
|
1209
|
+
if (!frontendApp) {
|
|
1210
|
+
logger.debug('Frontend: Serving default landing page');
|
|
1211
|
+
app.get('/', (_req, res) => {
|
|
777
1212
|
const html = generateDefaultLandingPageHtml(
|
|
778
1213
|
config.productName,
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
version,
|
|
782
|
-
config.logoUrl
|
|
1214
|
+
cpPath,
|
|
1215
|
+
logoIconUrl
|
|
783
1216
|
);
|
|
784
1217
|
res.type('html').send(html);
|
|
785
1218
|
});
|
|
786
1219
|
return;
|
|
787
1220
|
}
|
|
788
1221
|
|
|
789
|
-
const { redirectUrl, staticPath, landingPage } =
|
|
1222
|
+
const { redirectUrl, staticPath, landingPage } = frontendApp;
|
|
790
1223
|
|
|
791
1224
|
// Priority 1: Redirect
|
|
792
1225
|
if (redirectUrl) {
|
|
793
|
-
logger.
|
|
794
|
-
|
|
795
|
-
res.redirect(redirectUrl);
|
|
796
|
-
});
|
|
1226
|
+
logger.debug(`Frontend: Redirecting / to ${redirectUrl}`);
|
|
1227
|
+
app.get('/', (_req, res) => res.redirect(redirectUrl));
|
|
797
1228
|
return;
|
|
798
1229
|
}
|
|
799
1230
|
|
|
800
|
-
// Priority 2:
|
|
1231
|
+
// Priority 2: Static files
|
|
801
1232
|
if (staticPath && existsSync(staticPath)) {
|
|
802
|
-
logger.
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
// SPA fallback for root
|
|
806
|
-
controlPanel.app.get('/', (_req, res) => {
|
|
1233
|
+
logger.debug(`Frontend: Serving static from ${staticPath}`);
|
|
1234
|
+
app.use('/', express.static(staticPath));
|
|
1235
|
+
app.get('/', (_req, res) => {
|
|
807
1236
|
res.sendFile(resolve(staticPath, 'index.html'));
|
|
808
1237
|
});
|
|
809
1238
|
return;
|
|
@@ -811,74 +1240,116 @@ export function createGateway(
|
|
|
811
1240
|
|
|
812
1241
|
// Priority 3: Landing page
|
|
813
1242
|
if (landingPage) {
|
|
814
|
-
logger.
|
|
815
|
-
|
|
816
|
-
const html = generateLandingPageHtml(landingPage,
|
|
1243
|
+
logger.debug('Frontend: Serving custom landing page');
|
|
1244
|
+
app.get('/', (_req, res) => {
|
|
1245
|
+
const html = generateLandingPageHtml(landingPage, cpPath);
|
|
817
1246
|
res.type('html').send(html);
|
|
818
1247
|
});
|
|
819
1248
|
}
|
|
820
1249
|
};
|
|
821
1250
|
|
|
1251
|
+
/**
|
|
1252
|
+
* Start the gateway
|
|
1253
|
+
*/
|
|
822
1254
|
const start = async (): Promise<void> => {
|
|
823
|
-
logger.
|
|
1255
|
+
logger.debug('Starting gateway...');
|
|
824
1256
|
|
|
825
|
-
// 1. Start internal
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
logger.info(`Internal service started on port ${servicePort}`);
|
|
1257
|
+
// 1. Start internal control panel if enabled
|
|
1258
|
+
if (cpEnabled) {
|
|
1259
|
+
logger.debug(`Starting control panel on port ${cpPort}...`);
|
|
829
1260
|
|
|
830
|
-
|
|
831
|
-
|
|
1261
|
+
controlPanelInstance = createControlPanel({
|
|
1262
|
+
config: {
|
|
1263
|
+
productName: config.productName,
|
|
1264
|
+
port: cpPort,
|
|
1265
|
+
version,
|
|
1266
|
+
logoIconUrl: config.logoIconUrl,
|
|
1267
|
+
branding: config.branding,
|
|
1268
|
+
cors: config.corsOrigins ? { origins: config.corsOrigins } : undefined,
|
|
1269
|
+
mountPath: '/', // Control panel runs at / internally
|
|
1270
|
+
guard: cpConfig.guard,
|
|
1271
|
+
customUiPath: cpConfig.customUiPath,
|
|
1272
|
+
links: cpConfig.links,
|
|
1273
|
+
},
|
|
1274
|
+
plugins: cpConfig.plugins || [],
|
|
1275
|
+
logger,
|
|
1276
|
+
});
|
|
832
1277
|
|
|
833
|
-
|
|
834
|
-
|
|
1278
|
+
await controlPanelInstance.start();
|
|
1279
|
+
logger.debug(`Control panel started on port ${cpPort}`);
|
|
1280
|
+
}
|
|
835
1281
|
|
|
836
|
-
//
|
|
837
|
-
|
|
1282
|
+
// 2. Create HTTP server
|
|
1283
|
+
server = app.listen(gatewayPort);
|
|
838
1284
|
|
|
839
|
-
//
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
} else {
|
|
849
|
-
logger.info('Control Panel Auth: None (not recommended)');
|
|
1285
|
+
// 3. Setup mounted apps (proxy and static)
|
|
1286
|
+
const apps = config.apps || [];
|
|
1287
|
+
|
|
1288
|
+
// Add control panel as a proxy app if enabled
|
|
1289
|
+
if (cpEnabled) {
|
|
1290
|
+
setupProxyApp({
|
|
1291
|
+
path: cpPath,
|
|
1292
|
+
source: { type: 'proxy', target: `http://localhost:${cpPort}` },
|
|
1293
|
+
}, server);
|
|
850
1294
|
}
|
|
851
1295
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1296
|
+
// Setup additional apps
|
|
1297
|
+
for (const appConfig of apps) {
|
|
1298
|
+
if (appConfig.source.type === 'proxy') {
|
|
1299
|
+
setupProxyApp(appConfig, server);
|
|
1300
|
+
} else {
|
|
1301
|
+
setupStaticApp(appConfig);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// 4. Setup frontend app at root
|
|
1306
|
+
setupFrontendApp();
|
|
1307
|
+
|
|
1308
|
+
// Log startup info
|
|
1309
|
+
const authInfo = cpConfig.guard?.type === 'basic'
|
|
1310
|
+
? `(auth: ${cpConfig.guard.username})`
|
|
1311
|
+
: cpConfig.guard?.type && cpConfig.guard.type !== 'none'
|
|
1312
|
+
? `(auth: ${cpConfig.guard.type})`
|
|
1313
|
+
: '(no auth)';
|
|
1314
|
+
|
|
1315
|
+
logger.info(`${config.productName} gateway started on port ${gatewayPort} ${authInfo}`);
|
|
1316
|
+
|
|
1317
|
+
// Log mounted apps
|
|
1318
|
+
for (const mounted of mountedApps) {
|
|
1319
|
+
if (mounted.type === 'proxy') {
|
|
1320
|
+
logger.debug(` ${mounted.path}/* -> ${mounted.target}`);
|
|
1321
|
+
} else {
|
|
1322
|
+
logger.debug(` ${mounted.path}/* -> [static]`);
|
|
1323
|
+
}
|
|
858
1324
|
}
|
|
859
1325
|
};
|
|
860
1326
|
|
|
1327
|
+
/**
|
|
1328
|
+
* Stop the gateway
|
|
1329
|
+
*/
|
|
861
1330
|
const stop = async (): Promise<void> => {
|
|
862
|
-
logger.
|
|
1331
|
+
logger.debug('Shutting down gateway...');
|
|
863
1332
|
|
|
864
1333
|
// Stop control panel
|
|
865
|
-
|
|
1334
|
+
if (controlPanelInstance) {
|
|
1335
|
+
await controlPanelInstance.stop();
|
|
1336
|
+
}
|
|
866
1337
|
|
|
867
|
-
// Stop
|
|
868
|
-
if (
|
|
869
|
-
await
|
|
870
|
-
service.server.close();
|
|
1338
|
+
// Stop gateway server
|
|
1339
|
+
if (server) {
|
|
1340
|
+
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
|
871
1341
|
}
|
|
872
1342
|
|
|
873
|
-
logger.
|
|
1343
|
+
logger.debug('Gateway shutdown complete');
|
|
874
1344
|
};
|
|
875
1345
|
|
|
876
1346
|
return {
|
|
877
|
-
|
|
878
|
-
|
|
1347
|
+
app,
|
|
1348
|
+
server,
|
|
1349
|
+
controlPanel: controlPanelInstance,
|
|
1350
|
+
mountedApps,
|
|
879
1351
|
start,
|
|
880
1352
|
stop,
|
|
881
|
-
gatewayPort,
|
|
882
|
-
servicePort,
|
|
1353
|
+
port: gatewayPort,
|
|
883
1354
|
};
|
|
884
1355
|
}
|