@qwickapps/server 1.2.0 → 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 +238 -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 +92 -54
- 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 +679 -319
- 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 +114 -61
- package/src/core/gateway.ts +863 -403
- 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,238 +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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
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;
|
|
608
|
+
}
|
|
609
|
+
|
|
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);
|
|
623
724
|
}
|
|
624
725
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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>
|
|
629
897
|
</body>
|
|
630
898
|
</html>`;
|
|
631
899
|
}
|
|
632
900
|
|
|
633
901
|
/**
|
|
634
|
-
*
|
|
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}
|
|
1015
|
+
</body>
|
|
1016
|
+
</html>`;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Create a gateway that proxies to multiple internal services
|
|
635
1021
|
*
|
|
636
1022
|
* @param config - Gateway configuration
|
|
637
|
-
* @param serviceFactory - Factory function to create the internal service
|
|
638
1023
|
* @returns Gateway instance
|
|
639
1024
|
*
|
|
640
1025
|
* @example
|
|
641
1026
|
* ```typescript
|
|
642
1027
|
* import { createGateway } from '@qwickapps/server';
|
|
643
1028
|
*
|
|
644
|
-
* const gateway = createGateway(
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
1029
|
+
* const gateway = createGateway({
|
|
1030
|
+
* productName: 'My Product',
|
|
1031
|
+
* port: 3000,
|
|
1032
|
+
* controlPanel: {
|
|
1033
|
+
* path: '/cpanel',
|
|
1034
|
+
* port: 3001,
|
|
1035
|
+
* plugins: [...],
|
|
649
1036
|
* },
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
* server,
|
|
656
|
-
* shutdown: async () => { server.close(); },
|
|
657
|
-
* };
|
|
658
|
-
* }
|
|
659
|
-
* );
|
|
1037
|
+
* apps: [
|
|
1038
|
+
* { path: '/api', source: { type: 'proxy', target: 'http://localhost:3002' } },
|
|
1039
|
+
* { path: '/docs', source: { type: 'static', directory: './docs' } },
|
|
1040
|
+
* ],
|
|
1041
|
+
* });
|
|
660
1042
|
*
|
|
661
1043
|
* await gateway.start();
|
|
662
1044
|
* ```
|
|
663
1045
|
*/
|
|
664
|
-
export function createGateway(
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
): GatewayInstance {
|
|
668
|
-
// Initialize logging subsystem first
|
|
669
|
-
const loggingSubsystem = initializeLogging({
|
|
1046
|
+
export function createGateway(config: GatewayConfig): GatewayInstance {
|
|
1047
|
+
// Initialize logging (side effect - subsystem is initialized globally)
|
|
1048
|
+
initializeLogging({
|
|
670
1049
|
namespace: config.productName,
|
|
671
1050
|
...config.logging,
|
|
672
1051
|
});
|
|
673
1052
|
|
|
674
|
-
// Use provided logger or get one from the logging subsystem
|
|
675
1053
|
const logger = config.logger || getControlPanelLogger('Gateway');
|
|
676
1054
|
|
|
677
|
-
|
|
678
|
-
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);
|
|
679
1057
|
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
1058
|
+
const version = config.version || process.env.npm_package_version || '1.0.0';
|
|
680
1059
|
|
|
681
|
-
// Control panel
|
|
682
|
-
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;
|
|
683
1065
|
|
|
684
|
-
//
|
|
685
|
-
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'] = [];
|
|
686
1071
|
|
|
687
|
-
|
|
688
|
-
|
|
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;
|
|
689
1077
|
|
|
690
|
-
|
|
691
|
-
const version = config.version || process.env.npm_package_version || '1.0.0';
|
|
1078
|
+
if (source.type !== 'proxy') return;
|
|
692
1079
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
// Create control panel
|
|
696
|
-
const controlPanel = createControlPanel({
|
|
697
|
-
config: {
|
|
698
|
-
productName: config.productName,
|
|
699
|
-
port: gatewayPort,
|
|
700
|
-
version,
|
|
701
|
-
branding: config.branding,
|
|
702
|
-
cors: config.corsOrigins ? { origins: config.corsOrigins } : undefined,
|
|
703
|
-
// Skip body parsing for proxied paths
|
|
704
|
-
skipBodyParserPaths: [...proxyPaths, '/health'],
|
|
705
|
-
// Mount path for control panel
|
|
706
|
-
mountPath: controlPanelPath,
|
|
707
|
-
// Route guard
|
|
708
|
-
guard: guardConfig,
|
|
709
|
-
// Custom UI path
|
|
710
|
-
customUiPath: config.customUiPath,
|
|
711
|
-
links: config.links,
|
|
712
|
-
},
|
|
713
|
-
plugins: config.plugins || [],
|
|
714
|
-
logger,
|
|
715
|
-
});
|
|
1080
|
+
const appName = name || path.replace(/^\//, '') || 'Service';
|
|
1081
|
+
logger.debug(`Setting up proxy: ${path} -> ${source.target}`);
|
|
716
1082
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
if (res && 'writeHead' in res && !res.headersSent) {
|
|
731
|
-
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
732
|
-
res.end(
|
|
733
|
-
JSON.stringify({
|
|
734
|
-
error: 'Service Unavailable',
|
|
735
|
-
message: 'The service is currently unavailable. Please try again later.',
|
|
736
|
-
details: nodeEnv === 'development' ? err.message : undefined,
|
|
737
|
-
})
|
|
738
|
-
);
|
|
739
|
-
}
|
|
740
|
-
},
|
|
741
|
-
},
|
|
742
|
-
};
|
|
743
|
-
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
|
|
744
1096
|
}
|
|
745
1097
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1098
|
+
const proxyOptions: Options = {
|
|
1099
|
+
target: source.target,
|
|
1100
|
+
changeOrigin: true,
|
|
1101
|
+
ws: source.ws ?? false,
|
|
1102
|
+
pathRewrite: stripPrefix ? { [`^${path}`]: '' } : undefined,
|
|
751
1103
|
on: {
|
|
752
|
-
|
|
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
|
+
|
|
753
1111
|
if (res && 'writeHead' in res && !res.headersSent) {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
})
|
|
761
|
-
|
|
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
|
+
}
|
|
762
1130
|
}
|
|
763
1131
|
},
|
|
764
1132
|
},
|
|
765
1133
|
};
|
|
766
|
-
|
|
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 });
|
|
767
1150
|
};
|
|
768
1151
|
|
|
769
|
-
|
|
770
|
-
|
|
1152
|
+
/**
|
|
1153
|
+
* Setup static file serving for an app
|
|
1154
|
+
*/
|
|
1155
|
+
const setupStaticApp = (appConfig: MountedAppConfig) => {
|
|
1156
|
+
const { path, source } = appConfig;
|
|
771
1157
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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());
|
|
780
1195
|
});
|
|
781
|
-
logger.debug('Frontend app: Serving logo at /logo.svg');
|
|
782
1196
|
}
|
|
783
1197
|
}
|
|
784
1198
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1199
|
+
mountedApps.push({ path, type: 'static' });
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Setup frontend app at root path
|
|
1204
|
+
*/
|
|
1205
|
+
const setupFrontendApp = () => {
|
|
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) => {
|
|
789
1212
|
const html = generateDefaultLandingPageHtml(
|
|
790
1213
|
config.productName,
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
version,
|
|
794
|
-
config.logoUrl
|
|
1214
|
+
cpPath,
|
|
1215
|
+
logoIconUrl
|
|
795
1216
|
);
|
|
796
1217
|
res.type('html').send(html);
|
|
797
1218
|
});
|
|
798
1219
|
return;
|
|
799
1220
|
}
|
|
800
1221
|
|
|
801
|
-
const { redirectUrl, staticPath, landingPage } =
|
|
1222
|
+
const { redirectUrl, staticPath, landingPage } = frontendApp;
|
|
802
1223
|
|
|
803
1224
|
// Priority 1: Redirect
|
|
804
1225
|
if (redirectUrl) {
|
|
805
|
-
logger.debug(`Frontend
|
|
806
|
-
|
|
807
|
-
res.redirect(redirectUrl);
|
|
808
|
-
});
|
|
1226
|
+
logger.debug(`Frontend: Redirecting / to ${redirectUrl}`);
|
|
1227
|
+
app.get('/', (_req, res) => res.redirect(redirectUrl));
|
|
809
1228
|
return;
|
|
810
1229
|
}
|
|
811
1230
|
|
|
812
|
-
// Priority 2:
|
|
1231
|
+
// Priority 2: Static files
|
|
813
1232
|
if (staticPath && existsSync(staticPath)) {
|
|
814
|
-
logger.debug(`Frontend
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
// SPA fallback for root
|
|
818
|
-
controlPanel.app.get('/', (_req, res) => {
|
|
1233
|
+
logger.debug(`Frontend: Serving static from ${staticPath}`);
|
|
1234
|
+
app.use('/', express.static(staticPath));
|
|
1235
|
+
app.get('/', (_req, res) => {
|
|
819
1236
|
res.sendFile(resolve(staticPath, 'index.html'));
|
|
820
1237
|
});
|
|
821
1238
|
return;
|
|
@@ -823,73 +1240,116 @@ export function createGateway(
|
|
|
823
1240
|
|
|
824
1241
|
// Priority 3: Landing page
|
|
825
1242
|
if (landingPage) {
|
|
826
|
-
logger.debug(
|
|
827
|
-
|
|
828
|
-
const html = generateLandingPageHtml(landingPage,
|
|
1243
|
+
logger.debug('Frontend: Serving custom landing page');
|
|
1244
|
+
app.get('/', (_req, res) => {
|
|
1245
|
+
const html = generateLandingPageHtml(landingPage, cpPath);
|
|
829
1246
|
res.type('html').send(html);
|
|
830
1247
|
});
|
|
831
1248
|
}
|
|
832
1249
|
};
|
|
833
1250
|
|
|
1251
|
+
/**
|
|
1252
|
+
* Start the gateway
|
|
1253
|
+
*/
|
|
834
1254
|
const start = async (): Promise<void> => {
|
|
835
1255
|
logger.debug('Starting gateway...');
|
|
836
1256
|
|
|
837
|
-
// 1. Start internal
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
logger.debug(`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}...`);
|
|
841
1260
|
|
|
842
|
-
|
|
843
|
-
|
|
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
|
+
});
|
|
844
1277
|
|
|
845
|
-
|
|
846
|
-
|
|
1278
|
+
await controlPanelInstance.start();
|
|
1279
|
+
logger.debug(`Control panel started on port ${cpPort}`);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// 2. Create HTTP server
|
|
1283
|
+
server = app.listen(gatewayPort);
|
|
847
1284
|
|
|
848
|
-
//
|
|
849
|
-
|
|
1285
|
+
// 3. Setup mounted apps (proxy and static)
|
|
1286
|
+
const apps = config.apps || [];
|
|
850
1287
|
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
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})`
|
|
856
1313
|
: '(no auth)';
|
|
857
1314
|
|
|
858
|
-
logger.info(`${config.productName} started on port ${gatewayPort} ${authInfo}`);
|
|
1315
|
+
logger.info(`${config.productName} gateway started on port ${gatewayPort} ${authInfo}`);
|
|
859
1316
|
|
|
860
|
-
// Log
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
for (const apiPath of proxyPaths) {
|
|
868
|
-
logger.debug(`Service API: * ${apiPath}/*`);
|
|
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
|
+
}
|
|
869
1324
|
}
|
|
870
1325
|
};
|
|
871
1326
|
|
|
1327
|
+
/**
|
|
1328
|
+
* Stop the gateway
|
|
1329
|
+
*/
|
|
872
1330
|
const stop = async (): Promise<void> => {
|
|
873
1331
|
logger.debug('Shutting down gateway...');
|
|
874
1332
|
|
|
875
1333
|
// Stop control panel
|
|
876
|
-
|
|
1334
|
+
if (controlPanelInstance) {
|
|
1335
|
+
await controlPanelInstance.stop();
|
|
1336
|
+
}
|
|
877
1337
|
|
|
878
|
-
// Stop
|
|
879
|
-
if (
|
|
880
|
-
await
|
|
881
|
-
service.server.close();
|
|
1338
|
+
// Stop gateway server
|
|
1339
|
+
if (server) {
|
|
1340
|
+
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
|
882
1341
|
}
|
|
883
1342
|
|
|
884
1343
|
logger.debug('Gateway shutdown complete');
|
|
885
1344
|
};
|
|
886
1345
|
|
|
887
1346
|
return {
|
|
888
|
-
|
|
889
|
-
|
|
1347
|
+
app,
|
|
1348
|
+
server,
|
|
1349
|
+
controlPanel: controlPanelInstance,
|
|
1350
|
+
mountedApps,
|
|
890
1351
|
start,
|
|
891
1352
|
stop,
|
|
892
|
-
gatewayPort,
|
|
893
|
-
servicePort,
|
|
1353
|
+
port: gatewayPort,
|
|
894
1354
|
};
|
|
895
1355
|
}
|