lightman-agent 1.0.5 → 1.0.6

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.
Files changed (115) hide show
  1. package/agent.config.template.json +30 -30
  2. package/package.json +52 -52
  3. package/public/assets/index-CcBNCz6h.css +1 -1
  4. package/public/assets/index-D9QHMG8k.js +1 -0
  5. package/public/assets/index-H-8HDl46.js +1 -1
  6. package/public/assets/index-YodeiCia.css +1 -0
  7. package/public/assets/index-legacy-DWtNM8y7.js +41 -0
  8. package/public/assets/museum-map-CwVDA2z1.svg +4182 -0
  9. package/public/assets/polyfills-legacy-DyVYWHbW.js +4 -0
  10. package/public/index.html +7 -2
  11. package/public/templates/custom08/elements/back-button.svg +20 -0
  12. package/public/templates/custom08/elements/base-map-background.svg +37 -0
  13. package/public/templates/custom08/elements/base-map.svg +1191 -0
  14. package/public/templates/custom08/elements/gallery-1-2-3-info-panel.svg +236 -0
  15. package/public/templates/custom08/elements/gallery-4-5-6-7-info-panel.svg +266 -0
  16. package/public/templates/custom08/elements/gallery-8-9-info-panel.svg +274 -0
  17. package/public/templates/custom08/elements/gallery-labels/_nav-map-styles.css +554 -0
  18. package/public/templates/custom08/elements/gallery-labels/_styles.css +556 -0
  19. package/public/templates/custom08/elements/gallery-labels/gallery-1.svg +35 -0
  20. package/public/templates/custom08/elements/gallery-labels/gallery-2.svg +34 -0
  21. package/public/templates/custom08/elements/gallery-labels/gallery-3.svg +34 -0
  22. package/public/templates/custom08/elements/gallery-labels/gallery-4.svg +37 -0
  23. package/public/templates/custom08/elements/gallery-labels/gallery-5.svg +34 -0
  24. package/public/templates/custom08/elements/gallery-labels/gallery-6.svg +34 -0
  25. package/public/templates/custom08/elements/gallery-labels/gallery-7.svg +34 -0
  26. package/public/templates/custom08/elements/gallery-labels/gallery-8.svg +37 -0
  27. package/public/templates/custom08/elements/gallery-labels/gallery-9.svg +34 -0
  28. package/public/templates/custom08/elements/hand-hint.png +0 -0
  29. package/public/templates/custom08/elements/idle-screen-bg.svg +5 -0
  30. package/public/templates/custom08/elements/idle-screen-map.svg +627 -0
  31. package/public/templates/custom08/elements/idle-screen-text.svg +350 -0
  32. package/public/templates/custom08/elements/key-map-1.svg +986 -0
  33. package/public/templates/custom08/elements/key-map-2.svg +1018 -0
  34. package/public/templates/custom08/elements/key-map-3.svg +1019 -0
  35. package/public/templates/custom08/elements/key-map-combined.svg +1001 -0
  36. package/public/templates/custom08/elements/map-highlight-marker.svg +11 -0
  37. package/public/templates/custom08/elements/map-pin-marker.svg +15 -0
  38. package/public/templates/custom08/elements/map-teardrop-star-marker.svg +13 -0
  39. package/public/templates/custom08/elements/nav-circle-galleries-1-3.svg +21 -0
  40. package/public/templates/custom08/elements/nav-circle-galleries-4-7.svg +24 -0
  41. package/public/templates/custom08/elements/nav-circle-galleries-8-9.svg +20 -0
  42. package/public/templates/custom08/elements/section1-map.svg +1435 -0
  43. package/public/templates/custom08/elements/section2-map.svg +1724 -0
  44. package/public/templates/custom08/elements/section3-map.svg +1295 -0
  45. package/public/templates/custom08/fonts/CabinetGrotesk-Variable.ttf +0 -0
  46. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.12_PM.png +0 -0
  47. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.56_PM.png +0 -0
  48. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.24.24_PM.png +0 -0
  49. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.31.58_PM.jpg +0 -0
  50. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.11_PM.jpg +0 -0
  51. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.36_PM.jpg +0 -0
  52. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.48_PM.jpg +0 -0
  53. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.59_PM.jpg +0 -0
  54. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.15_PM.jpg +0 -0
  55. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.27_PM.jpg +0 -0
  56. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.34_PM.jpg +0 -0
  57. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.42_PM.jpg +0 -0
  58. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.50_PM.jpg +0 -0
  59. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.58_PM.jpg +0 -0
  60. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.04_PM.jpg +0 -0
  61. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.11_PM.jpg +0 -0
  62. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.20_PM.jpg +0 -0
  63. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.57_PM.jpg +0 -0
  64. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.03_PM.jpg +0 -0
  65. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.16_PM.jpg +0 -0
  66. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.23_PM.jpg +0 -0
  67. package/public/templates/custom08/images/highlights/prologue-highlight.png +0 -0
  68. package/scripts/guardian.ps1 +75 -75
  69. package/scripts/install-linux.sh +134 -134
  70. package/scripts/install-rpi.sh +117 -117
  71. package/scripts/install-windows.ps1 +505 -505
  72. package/scripts/launch-kiosk.vbs +101 -101
  73. package/scripts/lightman-agent.logrotate +12 -12
  74. package/scripts/lightman-agent.service +38 -38
  75. package/scripts/lightman-shell.bat +107 -107
  76. package/scripts/reinstall-windows.ps1 +26 -26
  77. package/scripts/restore-desktop.ps1 +32 -32
  78. package/scripts/setup.ps1 +116 -116
  79. package/scripts/setup.sh +115 -115
  80. package/scripts/sync-display.mjs +20 -0
  81. package/scripts/uninstall-linux.sh +50 -50
  82. package/scripts/uninstall-windows.ps1 +54 -54
  83. package/src/commands/display.ts +177 -177
  84. package/src/commands/kiosk.ts +113 -113
  85. package/src/commands/maintenance.ts +106 -106
  86. package/src/commands/network.ts +129 -129
  87. package/src/commands/power.ts +163 -163
  88. package/src/commands/rpi.ts +45 -45
  89. package/src/commands/screenshot.ts +166 -166
  90. package/src/commands/serial.ts +17 -17
  91. package/src/commands/update.ts +124 -124
  92. package/src/index.ts +652 -652
  93. package/src/lib/config.ts +69 -69
  94. package/src/lib/identity.ts +40 -40
  95. package/src/lib/logger.ts +137 -137
  96. package/src/lib/platform.ts +10 -10
  97. package/src/lib/rpi.ts +180 -180
  98. package/src/lib/screens.ts +128 -128
  99. package/src/lib/types.ts +176 -176
  100. package/src/services/commands.ts +107 -107
  101. package/src/services/health.ts +161 -161
  102. package/src/services/kiosk.ts +384 -384
  103. package/src/services/localEvents.ts +60 -60
  104. package/src/services/logForwarder.ts +72 -72
  105. package/src/services/multiScreenKiosk.ts +324 -324
  106. package/src/services/oscBridge.ts +186 -186
  107. package/src/services/powerScheduler.ts +260 -260
  108. package/src/services/provisioning.ts +120 -120
  109. package/src/services/serialBridge.ts +230 -230
  110. package/src/services/serviceLauncher.ts +183 -183
  111. package/src/services/staticServer.ts +226 -226
  112. package/src/services/updater.ts +249 -249
  113. package/src/services/watchdog.ts +310 -310
  114. package/src/services/websocket.ts +152 -152
  115. package/tsconfig.json +28 -28
@@ -1,226 +1,226 @@
1
- import { createServer, request as httpRequest, type IncomingMessage, type ServerResponse } from 'http';
2
- import { createReadStream, statSync, existsSync } from 'fs';
3
- import { resolve, extname, join } from 'path';
4
- import { WebSocket, WebSocketServer } from 'ws';
5
- import type { Logger } from '../lib/logger.js';
6
-
7
- const MIME: Record<string, string> = {
8
- '.html': 'text/html; charset=utf-8',
9
- '.js': 'application/javascript',
10
- '.css': 'text/css',
11
- '.json': 'application/json',
12
- '.png': 'image/png',
13
- '.jpg': 'image/jpeg',
14
- '.svg': 'image/svg+xml',
15
- '.ico': 'image/x-icon',
16
- '.woff': 'font/woff',
17
- '.woff2': 'font/woff2',
18
- '.ttf': 'font/ttf',
19
- '.mp4': 'video/mp4',
20
- '.webm': 'video/webm',
21
- };
22
-
23
- export class StaticServer {
24
- private port: number;
25
- private distPath: string;
26
- private serverUrl: string;
27
- private logger: Logger;
28
- private server: ReturnType<typeof createServer> | null = null;
29
-
30
- constructor(port: number, distPath: string, serverUrl: string, logger: Logger) {
31
- this.port = port;
32
- this.distPath = distPath;
33
- this.serverUrl = serverUrl;
34
- this.logger = logger;
35
- }
36
-
37
- start(): void {
38
- if (!existsSync(this.distPath)) {
39
- this.logger.warn(`StaticServer: dist path not found: ${this.distPath}`);
40
- return;
41
- }
42
-
43
- this.server = createServer((req: IncomingMessage, res: ServerResponse) => {
44
- this.handle(req, res);
45
- });
46
-
47
- // Proxy WebSocket upgrades (/ws/*) to the real server
48
- this.server.on('upgrade', (req, socket, head) => {
49
- const upstream = new URL(req.url || '/', this.serverUrl);
50
- const wsUrl = upstream.toString().replace(/^http/, 'ws');
51
- this.logger.debug(`WS proxy: ${req.url} → ${wsUrl}`);
52
-
53
- const upstreamWs = new WebSocket(wsUrl, {
54
- headers: { ...req.headers, host: new URL(this.serverUrl).host },
55
- });
56
-
57
- upstreamWs.on('open', () => {
58
- const wss = new WebSocketServer({ noServer: true });
59
- wss.handleUpgrade(req, socket, head, (clientWs) => {
60
- // Pipe client ↔ upstream
61
- clientWs.on('message', (data, isBinary) => {
62
- if (upstreamWs.readyState === WebSocket.OPEN) {
63
- upstreamWs.send(data, { binary: isBinary });
64
- }
65
- });
66
- upstreamWs.on('message', (data, isBinary) => {
67
- if (clientWs.readyState === WebSocket.OPEN) {
68
- clientWs.send(data, { binary: isBinary });
69
- }
70
- });
71
- clientWs.on('close', () => upstreamWs.close());
72
- upstreamWs.on('close', () => clientWs.close());
73
- clientWs.on('error', () => upstreamWs.close());
74
- upstreamWs.on('error', () => clientWs.close());
75
- });
76
- });
77
-
78
- upstreamWs.on('error', (err) => {
79
- this.logger.warn(`WS proxy error: ${err.message}`);
80
- socket.destroy();
81
- });
82
- });
83
-
84
- this.server.listen(this.port, '0.0.0.0', () => {
85
- this.logger.info(`Display static server listening on port ${this.port} (proxy → ${this.serverUrl})`);
86
- });
87
-
88
- this.server.on('error', (err) => {
89
- this.logger.error(`StaticServer error: ${err.message}`);
90
- });
91
- }
92
-
93
- stop(): void {
94
- if (this.server) {
95
- this.server.close();
96
- this.server = null;
97
- this.logger.info('Display static server stopped');
98
- }
99
- }
100
-
101
- private handle(req: IncomingMessage, res: ServerResponse): void {
102
- const rawUrl = req.url || '/';
103
- const path = rawUrl.split('?')[0];
104
-
105
- // Proxy /api/* and /storage/* to the real server
106
- if (path.startsWith('/api/') || path.startsWith('/storage/')) {
107
- this.proxyHttp(req, res);
108
- return;
109
- }
110
-
111
- // Proxy /display/* to the server first (always get latest build).
112
- // Only fall back to local files if server is unreachable.
113
- if (path.startsWith('/display')) {
114
- this.proxyWithFallback(req, res);
115
- return;
116
- }
117
-
118
- // Serve static files for non-display paths
119
- this.serveStatic(req, res);
120
- }
121
-
122
- private proxyHttp(req: IncomingMessage, res: ServerResponse): void {
123
- const target = new URL(this.serverUrl);
124
- const options = {
125
- hostname: target.hostname,
126
- port: target.port || 80,
127
- path: req.url,
128
- method: req.method,
129
- headers: { ...req.headers, host: target.host },
130
- };
131
-
132
- const proxy = httpRequest(options, (upstreamRes) => {
133
- res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
134
- upstreamRes.pipe(res);
135
- });
136
-
137
- proxy.on('error', (err) => {
138
- this.logger.warn(`HTTP proxy error: ${err.message}`);
139
- if (!res.headersSent) {
140
- res.writeHead(502);
141
- res.end('Bad Gateway');
142
- }
143
- });
144
-
145
- req.pipe(proxy);
146
- }
147
-
148
- /**
149
- * Try to proxy the request to the real server (latest display build).
150
- * If the server is unreachable, fall back to local static files.
151
- */
152
- private proxyWithFallback(req: IncomingMessage, res: ServerResponse): void {
153
- const target = new URL(this.serverUrl);
154
- let fell = false;
155
- const fallback = () => {
156
- if (fell || res.headersSent) return;
157
- fell = true;
158
- this.logger.debug(`Proxy failed for ${req.url}, serving from local files`);
159
- this.serveStatic(req, res);
160
- };
161
-
162
- const options = {
163
- hostname: target.hostname,
164
- port: target.port || 80,
165
- path: req.url,
166
- method: req.method,
167
- headers: { ...req.headers, host: target.host },
168
- timeout: 3000,
169
- };
170
-
171
- const proxy = httpRequest(options, (upstreamRes) => {
172
- const statusCode = upstreamRes.statusCode || 502;
173
-
174
- // If upstream display route is unavailable/misconfigured,
175
- // serve the local bundled display instead of surfacing server errors.
176
- if (statusCode >= 400) {
177
- upstreamRes.resume();
178
- fallback();
179
- return;
180
- }
181
-
182
- res.writeHead(statusCode, upstreamRes.headers);
183
- upstreamRes.pipe(res);
184
- });
185
-
186
- proxy.on('error', fallback);
187
- proxy.on('timeout', () => { proxy.destroy(); fallback(); });
188
-
189
- req.pipe(proxy);
190
- }
191
-
192
- private serveStatic(req: IncomingMessage, res: ServerResponse): void {
193
- const rawUrl = (req.url || '/').split('?')[0];
194
-
195
- // Strip /display prefix to map to dist files
196
- let filePath = rawUrl.startsWith('/display')
197
- ? rawUrl.slice('/display'.length) || '/'
198
- : rawUrl;
199
-
200
- let absPath = resolve(this.distPath, filePath.replace(/^\//, ''));
201
-
202
- // Security: stay inside distPath
203
- if (!absPath.startsWith(this.distPath)) {
204
- res.writeHead(403);
205
- res.end('Forbidden');
206
- return;
207
- }
208
-
209
- // SPA fallback
210
- if (!existsSync(absPath) || statSync(absPath).isDirectory()) {
211
- absPath = join(this.distPath, 'index.html');
212
- }
213
-
214
- if (!existsSync(absPath)) {
215
- res.writeHead(404);
216
- res.end('Not Found');
217
- return;
218
- }
219
-
220
- const ext = extname(absPath).toLowerCase();
221
- const mime = MIME[ext] || 'application/octet-stream';
222
-
223
- res.writeHead(200, { 'Content-Type': mime });
224
- createReadStream(absPath).pipe(res);
225
- }
226
- }
1
+ import { createServer, request as httpRequest, type IncomingMessage, type ServerResponse } from 'http';
2
+ import { createReadStream, statSync, existsSync } from 'fs';
3
+ import { resolve, extname, join } from 'path';
4
+ import { WebSocket, WebSocketServer } from 'ws';
5
+ import type { Logger } from '../lib/logger.js';
6
+
7
+ const MIME: Record<string, string> = {
8
+ '.html': 'text/html; charset=utf-8',
9
+ '.js': 'application/javascript',
10
+ '.css': 'text/css',
11
+ '.json': 'application/json',
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.svg': 'image/svg+xml',
15
+ '.ico': 'image/x-icon',
16
+ '.woff': 'font/woff',
17
+ '.woff2': 'font/woff2',
18
+ '.ttf': 'font/ttf',
19
+ '.mp4': 'video/mp4',
20
+ '.webm': 'video/webm',
21
+ };
22
+
23
+ export class StaticServer {
24
+ private port: number;
25
+ private distPath: string;
26
+ private serverUrl: string;
27
+ private logger: Logger;
28
+ private server: ReturnType<typeof createServer> | null = null;
29
+
30
+ constructor(port: number, distPath: string, serverUrl: string, logger: Logger) {
31
+ this.port = port;
32
+ this.distPath = distPath;
33
+ this.serverUrl = serverUrl;
34
+ this.logger = logger;
35
+ }
36
+
37
+ start(): void {
38
+ if (!existsSync(this.distPath)) {
39
+ this.logger.warn(`StaticServer: dist path not found: ${this.distPath}`);
40
+ return;
41
+ }
42
+
43
+ this.server = createServer((req: IncomingMessage, res: ServerResponse) => {
44
+ this.handle(req, res);
45
+ });
46
+
47
+ // Proxy WebSocket upgrades (/ws/*) to the real server
48
+ this.server.on('upgrade', (req, socket, head) => {
49
+ const upstream = new URL(req.url || '/', this.serverUrl);
50
+ const wsUrl = upstream.toString().replace(/^http/, 'ws');
51
+ this.logger.debug(`WS proxy: ${req.url} → ${wsUrl}`);
52
+
53
+ const upstreamWs = new WebSocket(wsUrl, {
54
+ headers: { ...req.headers, host: new URL(this.serverUrl).host },
55
+ });
56
+
57
+ upstreamWs.on('open', () => {
58
+ const wss = new WebSocketServer({ noServer: true });
59
+ wss.handleUpgrade(req, socket, head, (clientWs) => {
60
+ // Pipe client ↔ upstream
61
+ clientWs.on('message', (data, isBinary) => {
62
+ if (upstreamWs.readyState === WebSocket.OPEN) {
63
+ upstreamWs.send(data, { binary: isBinary });
64
+ }
65
+ });
66
+ upstreamWs.on('message', (data, isBinary) => {
67
+ if (clientWs.readyState === WebSocket.OPEN) {
68
+ clientWs.send(data, { binary: isBinary });
69
+ }
70
+ });
71
+ clientWs.on('close', () => upstreamWs.close());
72
+ upstreamWs.on('close', () => clientWs.close());
73
+ clientWs.on('error', () => upstreamWs.close());
74
+ upstreamWs.on('error', () => clientWs.close());
75
+ });
76
+ });
77
+
78
+ upstreamWs.on('error', (err) => {
79
+ this.logger.warn(`WS proxy error: ${err.message}`);
80
+ socket.destroy();
81
+ });
82
+ });
83
+
84
+ this.server.listen(this.port, '0.0.0.0', () => {
85
+ this.logger.info(`Display static server listening on port ${this.port} (proxy → ${this.serverUrl})`);
86
+ });
87
+
88
+ this.server.on('error', (err) => {
89
+ this.logger.error(`StaticServer error: ${err.message}`);
90
+ });
91
+ }
92
+
93
+ stop(): void {
94
+ if (this.server) {
95
+ this.server.close();
96
+ this.server = null;
97
+ this.logger.info('Display static server stopped');
98
+ }
99
+ }
100
+
101
+ private handle(req: IncomingMessage, res: ServerResponse): void {
102
+ const rawUrl = req.url || '/';
103
+ const path = rawUrl.split('?')[0];
104
+
105
+ // Proxy /api/* and /storage/* to the real server
106
+ if (path.startsWith('/api/') || path.startsWith('/storage/')) {
107
+ this.proxyHttp(req, res);
108
+ return;
109
+ }
110
+
111
+ // Proxy /display/* to the server first (always get latest build).
112
+ // Only fall back to local files if server is unreachable.
113
+ if (path.startsWith('/display')) {
114
+ this.proxyWithFallback(req, res);
115
+ return;
116
+ }
117
+
118
+ // Serve static files for non-display paths
119
+ this.serveStatic(req, res);
120
+ }
121
+
122
+ private proxyHttp(req: IncomingMessage, res: ServerResponse): void {
123
+ const target = new URL(this.serverUrl);
124
+ const options = {
125
+ hostname: target.hostname,
126
+ port: target.port || 80,
127
+ path: req.url,
128
+ method: req.method,
129
+ headers: { ...req.headers, host: target.host },
130
+ };
131
+
132
+ const proxy = httpRequest(options, (upstreamRes) => {
133
+ res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
134
+ upstreamRes.pipe(res);
135
+ });
136
+
137
+ proxy.on('error', (err) => {
138
+ this.logger.warn(`HTTP proxy error: ${err.message}`);
139
+ if (!res.headersSent) {
140
+ res.writeHead(502);
141
+ res.end('Bad Gateway');
142
+ }
143
+ });
144
+
145
+ req.pipe(proxy);
146
+ }
147
+
148
+ /**
149
+ * Try to proxy the request to the real server (latest display build).
150
+ * If the server is unreachable, fall back to local static files.
151
+ */
152
+ private proxyWithFallback(req: IncomingMessage, res: ServerResponse): void {
153
+ const target = new URL(this.serverUrl);
154
+ let fell = false;
155
+ const fallback = () => {
156
+ if (fell || res.headersSent) return;
157
+ fell = true;
158
+ this.logger.debug(`Proxy failed for ${req.url}, serving from local files`);
159
+ this.serveStatic(req, res);
160
+ };
161
+
162
+ const options = {
163
+ hostname: target.hostname,
164
+ port: target.port || 80,
165
+ path: req.url,
166
+ method: req.method,
167
+ headers: { ...req.headers, host: target.host },
168
+ timeout: 3000,
169
+ };
170
+
171
+ const proxy = httpRequest(options, (upstreamRes) => {
172
+ const statusCode = upstreamRes.statusCode || 502;
173
+
174
+ // If upstream display route is unavailable/misconfigured,
175
+ // serve the local bundled display instead of surfacing server errors.
176
+ if (statusCode >= 400) {
177
+ upstreamRes.resume();
178
+ fallback();
179
+ return;
180
+ }
181
+
182
+ res.writeHead(statusCode, upstreamRes.headers);
183
+ upstreamRes.pipe(res);
184
+ });
185
+
186
+ proxy.on('error', fallback);
187
+ proxy.on('timeout', () => { proxy.destroy(); fallback(); });
188
+
189
+ req.pipe(proxy);
190
+ }
191
+
192
+ private serveStatic(req: IncomingMessage, res: ServerResponse): void {
193
+ const rawUrl = (req.url || '/').split('?')[0];
194
+
195
+ // Strip /display prefix to map to dist files
196
+ let filePath = rawUrl.startsWith('/display')
197
+ ? rawUrl.slice('/display'.length) || '/'
198
+ : rawUrl;
199
+
200
+ let absPath = resolve(this.distPath, filePath.replace(/^\//, ''));
201
+
202
+ // Security: stay inside distPath
203
+ if (!absPath.startsWith(this.distPath)) {
204
+ res.writeHead(403);
205
+ res.end('Forbidden');
206
+ return;
207
+ }
208
+
209
+ // SPA fallback
210
+ if (!existsSync(absPath) || statSync(absPath).isDirectory()) {
211
+ absPath = join(this.distPath, 'index.html');
212
+ }
213
+
214
+ if (!existsSync(absPath)) {
215
+ res.writeHead(404);
216
+ res.end('Not Found');
217
+ return;
218
+ }
219
+
220
+ const ext = extname(absPath).toLowerCase();
221
+ const mime = MIME[ext] || 'application/octet-stream';
222
+
223
+ res.writeHead(200, { 'Content-Type': mime });
224
+ createReadStream(absPath).pipe(res);
225
+ }
226
+ }