jsgui3-server 0.0.150 → 0.0.152
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/.github/instructions/copilot.instructions.md +1 -0
- package/AGENTS.md +2 -0
- package/README.md +89 -13
- package/admin-ui/v1/controls/admin_shell.js +702 -669
- package/admin-ui/v1/server.js +14 -1
- package/docs/api-reference.md +504 -306
- package/docs/books/creating-a-new-admin-ui/README.md +20 -20
- package/docs/books/website-design/01-introduction.md +73 -0
- package/docs/books/website-design/02-current-state.md +195 -0
- package/docs/books/website-design/03-base-class.md +181 -0
- package/docs/books/website-design/04-webpage.md +307 -0
- package/docs/books/website-design/05-website.md +456 -0
- package/docs/books/website-design/06-pages-storage.md +170 -0
- package/docs/books/website-design/07-api-layer.md +285 -0
- package/docs/books/website-design/08-server-integration.md +271 -0
- package/docs/books/website-design/09-cross-agent-review.md +190 -0
- package/docs/books/website-design/10-open-questions.md +196 -0
- package/docs/books/website-design/11-converged-recommendation.md +205 -0
- package/docs/books/website-design/12-content-model.md +395 -0
- package/docs/books/website-design/13-webpage-module-spec.md +404 -0
- package/docs/books/website-design/14-website-module-spec.md +541 -0
- package/docs/books/website-design/15-multi-repo-plan.md +275 -0
- package/docs/books/website-design/16-minimal-first.md +203 -0
- package/docs/books/website-design/17-implementation-report-codex.md +81 -0
- package/docs/books/website-design/README.md +43 -0
- package/docs/comprehensive-documentation.md +220 -220
- package/docs/configuration-reference.md +281 -204
- package/docs/middleware-guide.md +236 -0
- package/docs/proposals/jsgui3-website-and-webpage-design-jsgui3-server-support.md +257 -0
- package/docs/proposals/jsgui3-website-and-webpage-design-review.md +73 -0
- package/docs/proposals/jsgui3-website-and-webpage-design.md +732 -0
- package/docs/swagger.md +316 -0
- package/docs/system-architecture.md +24 -18
- package/examples/controls/1) window/server.js +6 -1
- package/examples/controls/21) mvvm and declarative api/check.js +94 -0
- package/examples/controls/21) mvvm and declarative api/check_output.txt +25 -0
- package/examples/controls/21) mvvm and declarative api/check_output_2.txt +27 -0
- package/examples/controls/21) mvvm and declarative api/client.js +241 -0
- declarative api/e2e-screenshot-1-name-change.png +0 -0
- declarative api/e2e-screenshot-2-toggled.png +0 -0
- declarative api/e2e-screenshot-3-final.png +0 -0
- declarative api/e2e-screenshot-final.png +0 -0
- package/examples/controls/21) mvvm and declarative api/e2e-test.js +175 -0
- package/examples/controls/21) mvvm and declarative api/out.html +1 -0
- package/examples/controls/21) mvvm and declarative api/page_out.html +1 -0
- package/examples/controls/21) mvvm and declarative api/server.js +18 -0
- package/examples/data-views/01) query-endpoint/server.js +61 -0
- package/labs/website-design/001-base-class-overhead/check.js +162 -0
- package/labs/website-design/002-pages-storage/check.js +244 -0
- package/labs/website-design/002-pages-storage/results.txt +0 -0
- package/labs/website-design/003-type-detection/check.js +193 -0
- package/labs/website-design/003-type-detection/results.txt +0 -0
- package/labs/website-design/004-two-stage-validation/check.js +314 -0
- package/labs/website-design/004-two-stage-validation/results.txt +0 -0
- package/labs/website-design/005-normalize-input/check.js +303 -0
- package/labs/website-design/006-serve-website-spike/check.js +290 -0
- package/labs/website-design/README.md +34 -0
- package/labs/website-design/manifest.json +68 -0
- package/labs/website-design/run-all.js +60 -0
- package/middleware/compression.js +217 -0
- package/middleware/index.js +15 -0
- package/middleware/json-body.js +126 -0
- package/module.js +3 -0
- package/openapi.js +474 -0
- package/package.json +11 -8
- package/publishers/Publishers.js +6 -5
- package/publishers/http-function-publisher.js +135 -126
- package/publishers/http-webpage-publisher.js +89 -11
- package/publishers/query-publisher.js +116 -0
- package/publishers/swagger-publisher.js +203 -0
- package/publishers/swagger-ui.js +578 -0
- package/resources/adapters/array-adapter.js +143 -0
- package/resources/query-resource.js +131 -0
- package/serve-factory.js +756 -18
- package/server.js +502 -123
- package/tests/README.md +23 -1
- package/tests/admin-ui-jsgui-controls.test.js +16 -1
- package/tests/helpers/playwright-e2e-harness.js +326 -0
- package/tests/openapi.test.js +319 -0
- package/tests/playwright-smoke.test.js +134 -0
- package/tests/publish-enhancements.test.js +673 -0
- package/tests/query-publisher.test.js +430 -0
- package/tests/quick-json-body-test.js +169 -0
- package/tests/serve.test.js +425 -122
- package/tests/swagger-publisher.test.js +1076 -0
- package/tests/test-runner.js +1 -0
package/server.js
CHANGED
|
@@ -30,6 +30,7 @@ const Process_Resource = require('./resources/process-resource');
|
|
|
30
30
|
const Remote_Process_Resource = require('./resources/remote-process-resource');
|
|
31
31
|
|
|
32
32
|
const Static_Route_HTTP_Responder = require('./http/responders/static/Static_Route_HTTP_Responder');
|
|
33
|
+
const { get_port_or_free } = require('./port-utils');
|
|
33
34
|
|
|
34
35
|
const Publishers = require('./publishers/Publishers');
|
|
35
36
|
|
|
@@ -39,6 +40,8 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
39
40
|
}, __type_name) {
|
|
40
41
|
super();
|
|
41
42
|
this.http_servers = [];
|
|
43
|
+
this.listening_endpoints = [];
|
|
44
|
+
this._api_registry = [];
|
|
42
45
|
let disk_path_client_js;
|
|
43
46
|
if (spec.disk_path_client_js) {
|
|
44
47
|
disk_path_client_js = spec.disk_path_client_js;
|
|
@@ -67,6 +70,12 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
67
70
|
let name = spec.name || undefined;
|
|
68
71
|
Object.defineProperty(this, 'name', { get: () => name, set: value => name = value })
|
|
69
72
|
this.__type_name = __type_name || 'server';
|
|
73
|
+
|
|
74
|
+
// Middleware pipeline — an ordered array of (req, res, next) functions
|
|
75
|
+
// that run before every request reaches the router. Populated via
|
|
76
|
+
// server.use(fn). See docs/middleware-guide.md for details.
|
|
77
|
+
this._middleware = [];
|
|
78
|
+
|
|
70
79
|
const resource_pool = this.resource_pool = new Server_Resource_Pool({
|
|
71
80
|
'access': {
|
|
72
81
|
'full': ['server_admin']
|
|
@@ -145,35 +154,35 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
145
154
|
|
|
146
155
|
const Admin_Module_V1 = require('./admin-ui/v1/server');
|
|
147
156
|
if (admin_enabled) {
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
this.admin_v1 = new Admin_Module_V1(typeof admin_config === 'object' ? admin_config : {});
|
|
158
|
+
this.admin_v1.init(this);
|
|
150
159
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
// Register Admin V1 Page Route
|
|
161
|
+
let Admin_Shell_Control;
|
|
162
|
+
try {
|
|
163
|
+
Admin_Shell_Control = require('./admin-ui/v1/client').controls.Admin_Shell;
|
|
164
|
+
} catch (e) {
|
|
165
|
+
console.warn('Failed to load Admin_Shell control:', e.message || e);
|
|
166
|
+
}
|
|
158
167
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
if (Admin_Shell_Control) {
|
|
169
|
+
const admin_v1_app = new Webpage({
|
|
170
|
+
content: Admin_Shell_Control,
|
|
171
|
+
title: 'jsgui3 Admin Dashboard'
|
|
172
|
+
});
|
|
164
173
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
174
|
+
const admin_v1_publisher = new HTTP_Webpage_Publisher({
|
|
175
|
+
name: 'Admin_V1_Publisher',
|
|
176
|
+
webpage: admin_v1_app,
|
|
177
|
+
src_path_client_js: lib_path.join(__dirname, 'admin-ui/v1/client.js')
|
|
178
|
+
});
|
|
179
|
+
admin_v1_publisher.name = 'Admin_V1_Publisher';
|
|
171
180
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
const is_dev_defaults = !process.env.ADMIN_V1_PASSWORD && process.env.NODE_ENV !== 'production';
|
|
182
|
+
const login_hint = is_dev_defaults
|
|
183
|
+
? '<div class="hint dev-creds">Dev defaults active — username: <code>admin</code> password: <code>admin</code></div><div class="hint">Set ADMIN_V1_USER / ADMIN_V1_PASSWORD env vars for production.</div>'
|
|
184
|
+
: '<div class="hint">Sign in with your configured credentials.</div>';
|
|
185
|
+
const login_html = `<!doctype html>
|
|
177
186
|
<html>
|
|
178
187
|
<head>
|
|
179
188
|
<meta charset="utf-8" />
|
|
@@ -244,82 +253,82 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
244
253
|
</body>
|
|
245
254
|
</html>`;
|
|
246
255
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (!this.admin_v1 || !this.admin_v1.is_admin_read_request(req)) {
|
|
255
|
-
res.writeHead(302, { Location: '/admin/v1/login' });
|
|
256
|
-
res.end();
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
if (admin_v1_cached_html) {
|
|
260
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
261
|
-
res.end(admin_v1_cached_html);
|
|
262
|
-
} else {
|
|
263
|
-
res.writeHead(503, { 'Content-Type': 'text/plain' });
|
|
264
|
-
res.end('Admin UI v1 is loading, please retry...');
|
|
265
|
-
}
|
|
266
|
-
};
|
|
256
|
+
const serve_admin_v1_page = (req, res) => {
|
|
257
|
+
if (req && typeof req.url === 'string' && req.url.startsWith('/admin/v1/login')) {
|
|
258
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
259
|
+
res.end(login_html);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
267
262
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
263
|
+
if (!this.admin_v1 || !this.admin_v1.is_admin_read_request(req)) {
|
|
264
|
+
res.writeHead(302, { Location: '/admin/v1/login' });
|
|
265
|
+
res.end();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (admin_v1_cached_html) {
|
|
269
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
270
|
+
res.end(admin_v1_cached_html);
|
|
271
|
+
} else {
|
|
272
|
+
res.writeHead(503, { 'Content-Type': 'text/plain' });
|
|
273
|
+
res.end('Admin UI v1 is loading, please retry...');
|
|
274
|
+
}
|
|
275
|
+
};
|
|
272
276
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
277
|
+
server_router.set_route('/admin/v1/login', null, (req, res) => {
|
|
278
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
279
|
+
res.end(login_html);
|
|
280
|
+
});
|
|
276
281
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
// Inject viewport meta for mobile rendering
|
|
294
|
-
if (!html.includes('name="viewport"')) {
|
|
282
|
+
// Namespace admin v1 assets under /admin/v1/ to avoid
|
|
283
|
+
// colliding with the main app's /js/js.js and /css/css.css.
|
|
284
|
+
let admin_v1_cached_html = null;
|
|
285
|
+
|
|
286
|
+
admin_v1_publisher.on('ready', (res_ready) => {
|
|
287
|
+
if (res_ready && res_ready._arr) {
|
|
288
|
+
for (const bundle_item of res_ready._arr) {
|
|
289
|
+
const { type } = bundle_item;
|
|
290
|
+
if (type === 'HTML') {
|
|
291
|
+
// Rewrite asset references so browser fetches
|
|
292
|
+
// the admin-specific JS/CSS, not the main app's.
|
|
293
|
+
let html = bundle_item.text || '';
|
|
294
|
+
html = html.replace(
|
|
295
|
+
/href="\/css\/css\.css"/g,
|
|
296
|
+
'href="/admin/v1/css/css.css"'
|
|
297
|
+
);
|
|
295
298
|
html = html.replace(
|
|
296
|
-
|
|
297
|
-
'
|
|
299
|
+
/src="\/js\/js\.js"/g,
|
|
300
|
+
'src="/admin/v1/js/js.js"'
|
|
298
301
|
);
|
|
302
|
+
// Inject viewport meta for mobile rendering
|
|
303
|
+
if (!html.includes('name="viewport"')) {
|
|
304
|
+
html = html.replace(
|
|
305
|
+
/<head([^>]*)>/i,
|
|
306
|
+
'<head$1><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">'
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
admin_v1_cached_html = html;
|
|
310
|
+
} else {
|
|
311
|
+
// JS → /admin/v1/js/js.js, CSS → /admin/v1/css/css.css
|
|
312
|
+
const namespaced_route =
|
|
313
|
+
type === 'JavaScript' ? '/admin/v1/js/js.js' :
|
|
314
|
+
type === 'CSS' ? '/admin/v1/css/css.css' :
|
|
315
|
+
'/admin/v1' + bundle_item.route;
|
|
316
|
+
const responder = new Static_Route_HTTP_Responder(bundle_item);
|
|
317
|
+
server_router.set_route(namespaced_route, responder, responder.handle_http);
|
|
299
318
|
}
|
|
300
|
-
admin_v1_cached_html = html;
|
|
301
|
-
} else {
|
|
302
|
-
// JS → /admin/v1/js/js.js, CSS → /admin/v1/css/css.css
|
|
303
|
-
const namespaced_route =
|
|
304
|
-
type === 'JavaScript' ? '/admin/v1/js/js.js' :
|
|
305
|
-
type === 'CSS' ? '/admin/v1/css/css.css' :
|
|
306
|
-
'/admin/v1' + bundle_item.route;
|
|
307
|
-
const responder = new Static_Route_HTTP_Responder(bundle_item);
|
|
308
|
-
server_router.set_route(namespaced_route, responder, responder.handle_http);
|
|
309
319
|
}
|
|
310
|
-
}
|
|
311
320
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
321
|
+
// Serve authenticated admin page at /admin/v1
|
|
322
|
+
server_router.set_route('/admin/v1', null, serve_admin_v1_page);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
316
325
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
326
|
+
// Temporary handler until the publisher finishes bundling
|
|
327
|
+
server_router.set_route('/admin/v1', null, serve_admin_v1_page);
|
|
328
|
+
resource_pool.add(admin_v1_publisher);
|
|
329
|
+
} else {
|
|
330
|
+
console.warn('Skipping /admin/v1 route registration due to missing Admin_Shell control.');
|
|
331
|
+
}
|
|
323
332
|
} else {
|
|
324
333
|
// admin_enabled === false
|
|
325
334
|
this.admin_v1 = null;
|
|
@@ -450,20 +459,196 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
450
459
|
Object.defineProperty(this, 'router', { get: () => server_router })
|
|
451
460
|
}
|
|
452
461
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
462
|
+
/**
|
|
463
|
+
* Publish a JavaScript function as an HTTP API endpoint.
|
|
464
|
+
*
|
|
465
|
+
* The function is wrapped in a {@link Function_Publisher} and
|
|
466
|
+
* registered with the server's router. An entry is also added to
|
|
467
|
+
* `this._api_registry` so the OpenAPI spec generator can discover it.
|
|
468
|
+
*
|
|
469
|
+
* ### Route resolution
|
|
470
|
+
*
|
|
471
|
+
* - If `name` starts with `'/'`, it is used as-is (e.g. `'/health'`).
|
|
472
|
+
* - Otherwise it is auto-prefixed with `'/api/'` (e.g. `'users/list'` → `'/api/users/list'`).
|
|
473
|
+
*
|
|
474
|
+
* ### Metadata for Swagger
|
|
475
|
+
*
|
|
476
|
+
* The `meta` argument feeds the OpenAPI spec generator. All fields
|
|
477
|
+
* are optional — endpoints without metadata still work but produce
|
|
478
|
+
* a minimal Swagger entry.
|
|
479
|
+
*
|
|
480
|
+
* | Field | Type | Purpose |
|
|
481
|
+
* |---------------------|----------|----------------------------------------------|
|
|
482
|
+
* | `meta.method` | string | HTTP method (`'GET'`, `'POST'`, etc.) |
|
|
483
|
+
* | `meta.summary` | string | One-line summary displayed in Swagger UI |
|
|
484
|
+
* | `meta.description` | string | Multi-line Markdown description |
|
|
485
|
+
* | `meta.tags` | string[] | Grouping tags in Swagger UI |
|
|
486
|
+
* | `meta.params` | Object | Request body schema (`{key: {type, ...}}`) |
|
|
487
|
+
* | `meta.returns` | Object | Response body schema (`{key: {type, ...}}`) |
|
|
488
|
+
* | `meta.deprecated` | boolean | Mark endpoint as deprecated |
|
|
489
|
+
* | `meta.operationId` | string | Custom OpenAPI operationId |
|
|
490
|
+
* | `meta.raw` | boolean | Raw `(req, res)` handler — skip Function_Publisher |
|
|
491
|
+
*
|
|
492
|
+
* @param {string} name - Endpoint name or route path.
|
|
493
|
+
* @param {Function} fn - Handler function `(input) => result`, or `(req, res)` when `meta.raw` is `true`.
|
|
494
|
+
* @param {Object} [meta={}] - API metadata for routing and Swagger.
|
|
495
|
+
*
|
|
496
|
+
* @example
|
|
497
|
+
* // Minimal — just a name and function:
|
|
498
|
+
* server.publish('ping', () => ({ pong: true }));
|
|
499
|
+
* // → POST /api/ping
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* // With full metadata for Swagger:
|
|
503
|
+
* server.publish('users/list', listUsers, {
|
|
504
|
+
* method: 'POST',
|
|
505
|
+
* summary: 'List all users',
|
|
506
|
+
* description: 'Returns paginated user list with filtering.',
|
|
507
|
+
* tags: ['Users'],
|
|
508
|
+
* params: {
|
|
509
|
+
* page: { type: 'integer', description: 'Page number', default: 1 },
|
|
510
|
+
* page_size: { type: 'integer', description: 'Items per page', default: 25 }
|
|
511
|
+
* },
|
|
512
|
+
* returns: {
|
|
513
|
+
* rows: { type: 'array', items: { type: 'object' } },
|
|
514
|
+
* total_count: { type: 'integer' }
|
|
515
|
+
* }
|
|
516
|
+
* });
|
|
517
|
+
* // → POST /api/users/list (documented in Swagger UI)
|
|
518
|
+
*
|
|
519
|
+
* @example
|
|
520
|
+
* // Raw handler for streaming NDJSON:
|
|
521
|
+
* server.publish('events/stream', (req, res) => {
|
|
522
|
+
* res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
|
|
523
|
+
* res.write(JSON.stringify({ event: 'start' }) + '\n');
|
|
524
|
+
* res.end();
|
|
525
|
+
* }, { method: 'GET', raw: true, summary: 'Stream events' });
|
|
526
|
+
*/
|
|
527
|
+
publish(name, fn, meta = {}) {
|
|
528
|
+
// Auto-prefix /api/ for simple names.
|
|
529
|
+
// If name already starts with '/', use as-is for full route control.
|
|
530
|
+
const full_route = name.startsWith('/') ? name : '/api/' + name;
|
|
531
|
+
|
|
532
|
+
// ── Raw handler passthrough ──
|
|
533
|
+
// When meta.raw is true, fn receives (req, res) directly.
|
|
534
|
+
// Useful for streaming, SSE, gzip, or custom response control.
|
|
535
|
+
if (meta.raw) {
|
|
536
|
+
this._api_registry = this._api_registry || [];
|
|
537
|
+
this._api_registry.push({
|
|
538
|
+
path: full_route,
|
|
539
|
+
method: (meta.method && meta.method !== 'ANY') ? meta.method.toUpperCase() : 'GET',
|
|
540
|
+
meta,
|
|
541
|
+
schema: {}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
if (meta.method && meta.method !== 'ANY') {
|
|
545
|
+
const method = meta.method.toUpperCase();
|
|
546
|
+
this.server_router.set_route(full_route, null, (req, res) => {
|
|
547
|
+
if (req.method.toUpperCase() !== method && req.method.toUpperCase() !== 'HEAD') {
|
|
548
|
+
res.writeHead(405, { 'Allow': method });
|
|
549
|
+
res.end('Method Not Allowed');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
return fn(req, res);
|
|
553
|
+
});
|
|
554
|
+
} else {
|
|
555
|
+
this.server_router.set_route(full_route, null, fn);
|
|
556
|
+
}
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── Standard function publisher ──
|
|
561
|
+
const fpub = new HTTP_Function_Publisher({ name, fn, meta });
|
|
459
562
|
|
|
460
563
|
this.function_publishers = this.function_publishers || [];
|
|
461
564
|
this.function_publishers.push(fpub);
|
|
462
565
|
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
566
|
+
// Register in API registry for OpenAPI spec generation.
|
|
567
|
+
this._api_registry = this._api_registry || [];
|
|
568
|
+
this._api_registry.push({
|
|
569
|
+
path: full_route,
|
|
570
|
+
method: (meta.method && meta.method !== 'ANY') ? meta.method.toUpperCase() : 'POST',
|
|
571
|
+
meta,
|
|
572
|
+
schema: fpub.schema
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
if (meta.method && meta.method !== 'ANY') {
|
|
576
|
+
const method = meta.method.toUpperCase();
|
|
577
|
+
this.server_router.set_route(full_route, fpub, (req, res) => {
|
|
578
|
+
if (req.method.toUpperCase() !== method) {
|
|
579
|
+
res.writeHead(405, { 'Allow': method });
|
|
580
|
+
res.end('Method Not Allowed');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
return fpub.handle_http(req, res);
|
|
584
|
+
});
|
|
585
|
+
} else {
|
|
586
|
+
this.server_router.set_route(full_route, fpub, fpub.handle_http);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Register the built-in Swagger / OpenAPI routes on this server.
|
|
592
|
+
*
|
|
593
|
+
* Creates two endpoints:
|
|
594
|
+
*
|
|
595
|
+
* | Route | Method | Content-Type | Purpose |
|
|
596
|
+
* |----------------------|--------|--------------------|----------------------------------|
|
|
597
|
+
* | `/api/openapi.json` | GET | `application/json` | OpenAPI 3.0.3 spec (JSON) |
|
|
598
|
+
* | `/api/docs` | GET | `text/html` | Interactive Swagger UI page |
|
|
599
|
+
*
|
|
600
|
+
* The Swagger UI page loads its JS and CSS from the unpkg CDN at
|
|
601
|
+
* runtime, so zero npm dependencies are required. The page is
|
|
602
|
+
* styled to match jsgui3's dark aesthetic.
|
|
603
|
+
*
|
|
604
|
+
* ### Automatic registration
|
|
605
|
+
*
|
|
606
|
+
* When using `Server.serve()`, this method is called automatically
|
|
607
|
+
* after all endpoints are registered — unless `swagger: false` is
|
|
608
|
+
* set in the options. The default is:
|
|
609
|
+
*
|
|
610
|
+
* - **Development** (`NODE_ENV !== 'production'`): Swagger enabled.
|
|
611
|
+
* - **Production** (`NODE_ENV === 'production'`): Swagger disabled.
|
|
612
|
+
*
|
|
613
|
+
* ### Manual registration
|
|
614
|
+
*
|
|
615
|
+
* For servers created directly via `new Server(...)`, call this
|
|
616
|
+
* method after all `publish()` calls:
|
|
617
|
+
*
|
|
618
|
+
* ```js
|
|
619
|
+
* server._register_swagger_routes({ title: 'My API', version: '2.0.0' });
|
|
620
|
+
* ```
|
|
621
|
+
*
|
|
622
|
+
* This method is idempotent — calling it multiple times has no
|
|
623
|
+
* effect after the first call.
|
|
624
|
+
*
|
|
625
|
+
* @param {Object} [options] - Override options passed to the spec generator.
|
|
626
|
+
* @param {string} [options.title] - Override API title (default: server.name).
|
|
627
|
+
* @param {string} [options.version] - Override API version (default: '1.0.0').
|
|
628
|
+
* @param {string} [options.description] - Override API description.
|
|
629
|
+
*/
|
|
630
|
+
_register_swagger_routes(options = {}) {
|
|
631
|
+
if (this._swagger_registered) return;
|
|
632
|
+
this._swagger_registered = true;
|
|
633
|
+
|
|
634
|
+
const Swagger_Publisher = require('./publishers/swagger-publisher');
|
|
635
|
+
|
|
636
|
+
const swagger_pub = new Swagger_Publisher({
|
|
637
|
+
server: this,
|
|
638
|
+
title: options.title || this.name || 'API Documentation',
|
|
639
|
+
version: options.version,
|
|
640
|
+
description: options.description
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The Swagger_Publisher instance, stored for introspection.
|
|
645
|
+
* @type {Swagger_Publisher}
|
|
646
|
+
*/
|
|
647
|
+
this._swagger_publisher = swagger_pub;
|
|
648
|
+
|
|
649
|
+
// Register both routes pointing to the same publisher.
|
|
650
|
+
this.server_router.set_route('/api/openapi.json', swagger_pub, swagger_pub.handle_http.bind(swagger_pub));
|
|
651
|
+
this.server_router.set_route('/api/docs', swagger_pub, swagger_pub.handle_http.bind(swagger_pub));
|
|
467
652
|
}
|
|
468
653
|
|
|
469
654
|
publish_observable(route, obs, options = {}) {
|
|
@@ -482,11 +667,96 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
482
667
|
return this.publish_observable(route, obs, options);
|
|
483
668
|
}
|
|
484
669
|
|
|
670
|
+
/**
|
|
671
|
+
* Register middleware to run before every request is routed.
|
|
672
|
+
*
|
|
673
|
+
* Middleware signature: `function (req, res, next) { ... }`
|
|
674
|
+
* Call `next()` to continue to the next middleware / router.
|
|
675
|
+
* Call `next(err)` to short-circuit into the error handler.
|
|
676
|
+
*
|
|
677
|
+
* @param {function} fn Middleware function.
|
|
678
|
+
* @returns {this} The server instance (for chaining).
|
|
679
|
+
*
|
|
680
|
+
* @example
|
|
681
|
+
* const { compression } = require('jsgui3-server/middleware');
|
|
682
|
+
* server.use(compression());
|
|
683
|
+
*/
|
|
684
|
+
use(fn) {
|
|
685
|
+
if (typeof fn !== 'function') {
|
|
686
|
+
throw new Error('Middleware must be a function (req, res, next).');
|
|
687
|
+
}
|
|
688
|
+
this._middleware.push(fn);
|
|
689
|
+
return this;
|
|
690
|
+
}
|
|
485
691
|
|
|
486
692
|
get resource_names() {
|
|
487
693
|
return this.resource_pool.resource_names;
|
|
488
694
|
}
|
|
695
|
+
|
|
696
|
+
get_listening_endpoints() {
|
|
697
|
+
if (!this.listening_endpoints || !this.listening_endpoints.length) {
|
|
698
|
+
return [];
|
|
699
|
+
}
|
|
700
|
+
return this.listening_endpoints.map(endpoint => ({ ...endpoint }));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
get_primary_endpoint() {
|
|
704
|
+
const endpoints = this.get_listening_endpoints();
|
|
705
|
+
if (!endpoints.length) return null;
|
|
706
|
+
return endpoints[0].url;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
get_startup_diagnostics() {
|
|
710
|
+
if (!this.startup_diagnostics) {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
...this.startup_diagnostics,
|
|
715
|
+
addresses_attempted: Array.isArray(this.startup_diagnostics.addresses_attempted)
|
|
716
|
+
? [...this.startup_diagnostics.addresses_attempted]
|
|
717
|
+
: [],
|
|
718
|
+
errors_by_address: this.startup_diagnostics.errors_by_address
|
|
719
|
+
? { ...this.startup_diagnostics.errors_by_address }
|
|
720
|
+
: {}
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
print_endpoints(options = {}) {
|
|
725
|
+
const endpoints = this.get_listening_endpoints();
|
|
726
|
+
const logger = typeof options.logger === 'function' ? options.logger : console.log;
|
|
727
|
+
const include_index = !!options.include_index;
|
|
728
|
+
const prefix = typeof options.prefix === 'string' ? options.prefix : 'listening endpoint';
|
|
729
|
+
|
|
730
|
+
if (!endpoints.length) {
|
|
731
|
+
logger('no listening endpoints');
|
|
732
|
+
return [];
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const lines = endpoints.map((endpoint, index) => {
|
|
736
|
+
if (include_index) {
|
|
737
|
+
return `${prefix} [${index}]: ${endpoint.url}`;
|
|
738
|
+
}
|
|
739
|
+
return `${prefix}: ${endpoint.url}`;
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
lines.forEach((line) => logger(line));
|
|
743
|
+
return lines;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
_record_listening_endpoint(protocol, host, port) {
|
|
747
|
+
this.listening_endpoints = this.listening_endpoints || [];
|
|
748
|
+
this.listening_endpoints.push({
|
|
749
|
+
protocol,
|
|
750
|
+
host,
|
|
751
|
+
port,
|
|
752
|
+
url: `${protocol}://${host}:${port}/`
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
489
756
|
'start'(port, callback, fnProcessRequest) {
|
|
757
|
+
const start_options = (fnProcessRequest && typeof fnProcessRequest === 'object') ? fnProcessRequest : {};
|
|
758
|
+
const fallback_on_port_conflict = start_options.on_port_conflict === 'auto-loopback';
|
|
759
|
+
|
|
490
760
|
// Guard against double-start which causes EADDRINUSE
|
|
491
761
|
if (this._started) {
|
|
492
762
|
console.warn('Server.start() called but server already started. Ignoring duplicate call.');
|
|
@@ -494,6 +764,7 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
494
764
|
return;
|
|
495
765
|
}
|
|
496
766
|
this._started = true;
|
|
767
|
+
this.listening_endpoints = [];
|
|
497
768
|
|
|
498
769
|
if (tof(port) !== 'number') {
|
|
499
770
|
console.log('Invalid port:', port);
|
|
@@ -507,12 +778,14 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
507
778
|
this.raise('starting');
|
|
508
779
|
rp.start(err => {
|
|
509
780
|
if (err) {
|
|
781
|
+
this._started = false;
|
|
510
782
|
throw err;
|
|
511
783
|
} else {
|
|
512
784
|
const lsi = rp.get_resource('Local Server Info');
|
|
513
785
|
const server_router = rp.get_resource('Server Router');
|
|
514
786
|
lsi.getters.net((err, net) => {
|
|
515
787
|
if (err) {
|
|
788
|
+
this._started = false;
|
|
516
789
|
callback(err);
|
|
517
790
|
} else {
|
|
518
791
|
// NEW: Filter addresses by allowed_addresses if specified.
|
|
@@ -532,11 +805,47 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
532
805
|
let num_to_start = arr_ipv4_addresses.length;
|
|
533
806
|
let started_count = 0;
|
|
534
807
|
let last_error = null;
|
|
808
|
+
const errors_by_address = {};
|
|
535
809
|
let ready_raised = false;
|
|
810
|
+
let fallback_attempted = false;
|
|
536
811
|
if (num_to_start === 0) {
|
|
537
|
-
|
|
812
|
+
const no_interface_error = new Error('No allowed network interfaces found.');
|
|
813
|
+
no_interface_error.code = 'ENOINTERFACES';
|
|
814
|
+
this._started = false;
|
|
815
|
+
if (callback) callback(no_interface_error);
|
|
538
816
|
return;
|
|
539
817
|
}
|
|
818
|
+
|
|
819
|
+
const start_loopback_fallback = async (process_request) => {
|
|
820
|
+
const fallback_host = '127.0.0.1';
|
|
821
|
+
const fallback_port = await get_port_or_free(0, fallback_host);
|
|
822
|
+
const fallback_protocol = this.https_options ? 'https' : 'http';
|
|
823
|
+
const fallback_server = this.https_options
|
|
824
|
+
? https.createServer(this.https_options, (req, res) => process_request(req, res))
|
|
825
|
+
: http.createServer((req, res) => process_request(req, res));
|
|
826
|
+
this.http_servers.push(fallback_server);
|
|
827
|
+
fallback_server.timeout = 10800000;
|
|
828
|
+
await new Promise((resolve, reject) => {
|
|
829
|
+
fallback_server.once('error', reject);
|
|
830
|
+
fallback_server.listen(fallback_port, fallback_host, resolve);
|
|
831
|
+
});
|
|
832
|
+
this._record_listening_endpoint(fallback_protocol, fallback_host, fallback_port);
|
|
833
|
+
if (!ready_raised) {
|
|
834
|
+
console.warn(`[server.start] Port conflict fallback engaged. Listening on ${fallback_host}:${fallback_port}`);
|
|
835
|
+
this.raise('listening');
|
|
836
|
+
ready_raised = true;
|
|
837
|
+
}
|
|
838
|
+
this.port = fallback_port;
|
|
839
|
+
this.startup_diagnostics = {
|
|
840
|
+
requested_port: port,
|
|
841
|
+
fallback_port,
|
|
842
|
+
fallback_host,
|
|
843
|
+
addresses_attempted: arr_ipv4_addresses,
|
|
844
|
+
errors_by_address
|
|
845
|
+
};
|
|
846
|
+
if (callback) callback(null, true);
|
|
847
|
+
};
|
|
848
|
+
|
|
540
849
|
const finalize_start = (err) => {
|
|
541
850
|
if (num_to_start !== 0) return;
|
|
542
851
|
if (started_count > 0) {
|
|
@@ -545,10 +854,32 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
545
854
|
this.raise('listening'); // Changed from 'ready' to avoid double-fire
|
|
546
855
|
ready_raised = true;
|
|
547
856
|
}
|
|
857
|
+
this.port = port;
|
|
858
|
+
this.startup_diagnostics = {
|
|
859
|
+
requested_port: port,
|
|
860
|
+
addresses_attempted: arr_ipv4_addresses,
|
|
861
|
+
errors_by_address
|
|
862
|
+
};
|
|
548
863
|
if (callback) callback(null, true);
|
|
549
864
|
return;
|
|
550
865
|
}
|
|
551
866
|
const final_error = err || last_error || new Error('No servers started.');
|
|
867
|
+
final_error.startup_diagnostics = {
|
|
868
|
+
requested_port: port,
|
|
869
|
+
addresses_attempted: arr_ipv4_addresses,
|
|
870
|
+
errors_by_address
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
if (fallback_on_port_conflict && !fallback_attempted && final_error && final_error.code === 'EADDRINUSE') {
|
|
874
|
+
fallback_attempted = true;
|
|
875
|
+
start_loopback_fallback(process_request).catch((fallback_err) => {
|
|
876
|
+
this._started = false;
|
|
877
|
+
if (callback) callback(fallback_err);
|
|
878
|
+
});
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
this._started = false;
|
|
552
883
|
if (callback) callback(final_error);
|
|
553
884
|
};
|
|
554
885
|
const respond_not_found = (res) => {
|
|
@@ -576,33 +907,63 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
576
907
|
}
|
|
577
908
|
};
|
|
578
909
|
|
|
910
|
+
// Central request handler — runs the middleware chain then
|
|
911
|
+
// forwards to the router. If the middleware array is empty
|
|
912
|
+
// (the common case before server.use() is called), the
|
|
913
|
+
// router is invoked directly with zero overhead.
|
|
579
914
|
const process_request = (req, res) => {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
if (!outcome) {
|
|
588
|
-
if (!res.writableEnded) {
|
|
589
|
-
respond_not_found(res);
|
|
915
|
+
const route_request = () => {
|
|
916
|
+
let outcome;
|
|
917
|
+
try {
|
|
918
|
+
outcome = server_router.process(req, res);
|
|
919
|
+
} catch (err) {
|
|
920
|
+
respond_error(res, err);
|
|
921
|
+
return;
|
|
590
922
|
}
|
|
591
|
-
|
|
592
|
-
}
|
|
593
|
-
if (typeof outcome === 'object') {
|
|
594
|
-
if (outcome.status === 'error') {
|
|
595
|
-
if (!res.writableEnded) {
|
|
596
|
-
respond_error(res, outcome.error);
|
|
597
|
-
}
|
|
598
|
-
} else if (outcome.handled !== true && outcome.status === 'not-found') {
|
|
923
|
+
if (!outcome) {
|
|
599
924
|
if (!res.writableEnded) {
|
|
600
925
|
respond_not_found(res);
|
|
601
926
|
}
|
|
927
|
+
return;
|
|
602
928
|
}
|
|
603
|
-
|
|
604
|
-
|
|
929
|
+
if (typeof outcome === 'object') {
|
|
930
|
+
if (outcome.status === 'error') {
|
|
931
|
+
if (!res.writableEnded) {
|
|
932
|
+
respond_error(res, outcome.error);
|
|
933
|
+
}
|
|
934
|
+
} else if (outcome.handled !== true && outcome.status === 'not-found') {
|
|
935
|
+
if (!res.writableEnded) {
|
|
936
|
+
respond_not_found(res);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
} else if (outcome === false && !res.writableEnded) {
|
|
940
|
+
respond_not_found(res);
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// ── Middleware chain ──────────────────────────
|
|
945
|
+
// Walk through this._middleware in order. Each
|
|
946
|
+
// middleware calls next() to advance; next(err)
|
|
947
|
+
// short-circuits into respond_error. After the
|
|
948
|
+
// last middleware calls next(), route_request()
|
|
949
|
+
// hands off to the router.
|
|
950
|
+
const middleware = this._middleware;
|
|
951
|
+
if (!middleware.length) {
|
|
952
|
+
route_request();
|
|
953
|
+
return;
|
|
605
954
|
}
|
|
955
|
+
let idx = 0;
|
|
956
|
+
const next = (err) => {
|
|
957
|
+
if (err) { respond_error(res, err); return; }
|
|
958
|
+
if (idx >= middleware.length) { route_request(); return; }
|
|
959
|
+
const mw = middleware[idx++];
|
|
960
|
+
try {
|
|
961
|
+
mw(req, res, next);
|
|
962
|
+
} catch (e) {
|
|
963
|
+
respond_error(res, e);
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
next();
|
|
606
967
|
};
|
|
607
968
|
|
|
608
969
|
if (this.https_options) {
|
|
@@ -614,6 +975,10 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
614
975
|
this.http_servers.push(https_server);
|
|
615
976
|
https_server.on('error', (err) => {
|
|
616
977
|
last_error = err;
|
|
978
|
+
errors_by_address[ipv4_address] = {
|
|
979
|
+
code: err.code,
|
|
980
|
+
message: err.message
|
|
981
|
+
};
|
|
617
982
|
if (err.code === 'EACCES') {
|
|
618
983
|
console.error('Permission denied:', err.message);
|
|
619
984
|
} else if (err.code === 'EADDRINUSE') {
|
|
@@ -626,6 +991,7 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
626
991
|
});
|
|
627
992
|
https_server.timeout = 10800000;
|
|
628
993
|
https_server.listen(port, ipv4_address, () => {
|
|
994
|
+
this._record_listening_endpoint('https', ipv4_address, port);
|
|
629
995
|
console.log('* Server running at https://' + ipv4_address + ':' + port + '/');
|
|
630
996
|
started_count++;
|
|
631
997
|
num_to_start--;
|
|
@@ -646,6 +1012,10 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
646
1012
|
this.http_servers.push(http_server);
|
|
647
1013
|
http_server.on('error', (err) => {
|
|
648
1014
|
last_error = err;
|
|
1015
|
+
errors_by_address[ipv4_address] = {
|
|
1016
|
+
code: err.code,
|
|
1017
|
+
message: err.message
|
|
1018
|
+
};
|
|
649
1019
|
if (err.code === 'EACCES') {
|
|
650
1020
|
console.error('Permission denied:', err.message);
|
|
651
1021
|
} else if (err.code === 'EADDRINUSE') {
|
|
@@ -658,6 +1028,7 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
658
1028
|
});
|
|
659
1029
|
http_server.timeout = 10800000;
|
|
660
1030
|
http_server.listen(port, ipv4_address, () => {
|
|
1031
|
+
this._record_listening_endpoint('http', ipv4_address, port);
|
|
661
1032
|
console.log('* Server running at http://' + ipv4_address + ':' + port + '/');
|
|
662
1033
|
started_count++;
|
|
663
1034
|
num_to_start--;
|
|
@@ -704,6 +1075,8 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
704
1075
|
let count = this.http_servers.length;
|
|
705
1076
|
if (count === 0) {
|
|
706
1077
|
this.http_servers = [];
|
|
1078
|
+
this.listening_endpoints = [];
|
|
1079
|
+
this.startup_diagnostics = null;
|
|
707
1080
|
done();
|
|
708
1081
|
return;
|
|
709
1082
|
}
|
|
@@ -713,6 +1086,8 @@ class JSGUI_Single_Process_Server extends Evented_Class {
|
|
|
713
1086
|
count--;
|
|
714
1087
|
if (count === 0) {
|
|
715
1088
|
this.http_servers = [];
|
|
1089
|
+
this.listening_endpoints = [];
|
|
1090
|
+
this.startup_diagnostics = null;
|
|
716
1091
|
done();
|
|
717
1092
|
}
|
|
718
1093
|
});
|
|
@@ -777,6 +1152,10 @@ JSGUI_Single_Process_Server.Admin_Module_V1 = require('./admin-ui/v1/server');
|
|
|
777
1152
|
JSGUI_Single_Process_Server.Admin_Auth_Service = require('./admin-ui/v1/admin_auth_service');
|
|
778
1153
|
JSGUI_Single_Process_Server.Admin_User_Store = require('./admin-ui/v1/admin_user_store');
|
|
779
1154
|
|
|
1155
|
+
// Built-in middleware — accessed as Server.middleware.compression etc.
|
|
1156
|
+
// See docs/middleware-guide.md for the full API reference.
|
|
1157
|
+
JSGUI_Single_Process_Server.middleware = require('./middleware');
|
|
1158
|
+
|
|
780
1159
|
JSGUI_Single_Process_Server.serve = require('./serve-factory')(JSGUI_Single_Process_Server);
|
|
781
1160
|
|
|
782
1161
|
module.exports = JSGUI_Single_Process_Server;
|