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.
Files changed (86) hide show
  1. package/.github/instructions/copilot.instructions.md +1 -0
  2. package/AGENTS.md +2 -0
  3. package/README.md +89 -13
  4. package/admin-ui/v1/controls/admin_shell.js +702 -669
  5. package/admin-ui/v1/server.js +14 -1
  6. package/docs/api-reference.md +504 -306
  7. package/docs/books/creating-a-new-admin-ui/README.md +20 -20
  8. package/docs/books/website-design/01-introduction.md +73 -0
  9. package/docs/books/website-design/02-current-state.md +195 -0
  10. package/docs/books/website-design/03-base-class.md +181 -0
  11. package/docs/books/website-design/04-webpage.md +307 -0
  12. package/docs/books/website-design/05-website.md +456 -0
  13. package/docs/books/website-design/06-pages-storage.md +170 -0
  14. package/docs/books/website-design/07-api-layer.md +285 -0
  15. package/docs/books/website-design/08-server-integration.md +271 -0
  16. package/docs/books/website-design/09-cross-agent-review.md +190 -0
  17. package/docs/books/website-design/10-open-questions.md +196 -0
  18. package/docs/books/website-design/11-converged-recommendation.md +205 -0
  19. package/docs/books/website-design/12-content-model.md +395 -0
  20. package/docs/books/website-design/13-webpage-module-spec.md +404 -0
  21. package/docs/books/website-design/14-website-module-spec.md +541 -0
  22. package/docs/books/website-design/15-multi-repo-plan.md +275 -0
  23. package/docs/books/website-design/16-minimal-first.md +203 -0
  24. package/docs/books/website-design/17-implementation-report-codex.md +81 -0
  25. package/docs/books/website-design/README.md +43 -0
  26. package/docs/comprehensive-documentation.md +220 -220
  27. package/docs/configuration-reference.md +281 -204
  28. package/docs/middleware-guide.md +236 -0
  29. package/docs/proposals/jsgui3-website-and-webpage-design-jsgui3-server-support.md +257 -0
  30. package/docs/proposals/jsgui3-website-and-webpage-design-review.md +73 -0
  31. package/docs/proposals/jsgui3-website-and-webpage-design.md +732 -0
  32. package/docs/swagger.md +316 -0
  33. package/docs/system-architecture.md +24 -18
  34. package/examples/controls/1) window/server.js +6 -1
  35. package/examples/controls/21) mvvm and declarative api/check.js +94 -0
  36. package/examples/controls/21) mvvm and declarative api/check_output.txt +25 -0
  37. package/examples/controls/21) mvvm and declarative api/check_output_2.txt +27 -0
  38. package/examples/controls/21) mvvm and declarative api/client.js +241 -0
  39. declarative api/e2e-screenshot-1-name-change.png +0 -0
  40. declarative api/e2e-screenshot-2-toggled.png +0 -0
  41. declarative api/e2e-screenshot-3-final.png +0 -0
  42. declarative api/e2e-screenshot-final.png +0 -0
  43. package/examples/controls/21) mvvm and declarative api/e2e-test.js +175 -0
  44. package/examples/controls/21) mvvm and declarative api/out.html +1 -0
  45. package/examples/controls/21) mvvm and declarative api/page_out.html +1 -0
  46. package/examples/controls/21) mvvm and declarative api/server.js +18 -0
  47. package/examples/data-views/01) query-endpoint/server.js +61 -0
  48. package/labs/website-design/001-base-class-overhead/check.js +162 -0
  49. package/labs/website-design/002-pages-storage/check.js +244 -0
  50. package/labs/website-design/002-pages-storage/results.txt +0 -0
  51. package/labs/website-design/003-type-detection/check.js +193 -0
  52. package/labs/website-design/003-type-detection/results.txt +0 -0
  53. package/labs/website-design/004-two-stage-validation/check.js +314 -0
  54. package/labs/website-design/004-two-stage-validation/results.txt +0 -0
  55. package/labs/website-design/005-normalize-input/check.js +303 -0
  56. package/labs/website-design/006-serve-website-spike/check.js +290 -0
  57. package/labs/website-design/README.md +34 -0
  58. package/labs/website-design/manifest.json +68 -0
  59. package/labs/website-design/run-all.js +60 -0
  60. package/middleware/compression.js +217 -0
  61. package/middleware/index.js +15 -0
  62. package/middleware/json-body.js +126 -0
  63. package/module.js +3 -0
  64. package/openapi.js +474 -0
  65. package/package.json +11 -8
  66. package/publishers/Publishers.js +6 -5
  67. package/publishers/http-function-publisher.js +135 -126
  68. package/publishers/http-webpage-publisher.js +89 -11
  69. package/publishers/query-publisher.js +116 -0
  70. package/publishers/swagger-publisher.js +203 -0
  71. package/publishers/swagger-ui.js +578 -0
  72. package/resources/adapters/array-adapter.js +143 -0
  73. package/resources/query-resource.js +131 -0
  74. package/serve-factory.js +756 -18
  75. package/server.js +502 -123
  76. package/tests/README.md +23 -1
  77. package/tests/admin-ui-jsgui-controls.test.js +16 -1
  78. package/tests/helpers/playwright-e2e-harness.js +326 -0
  79. package/tests/openapi.test.js +319 -0
  80. package/tests/playwright-smoke.test.js +134 -0
  81. package/tests/publish-enhancements.test.js +673 -0
  82. package/tests/query-publisher.test.js +430 -0
  83. package/tests/quick-json-body-test.js +169 -0
  84. package/tests/serve.test.js +425 -122
  85. package/tests/swagger-publisher.test.js +1076 -0
  86. 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
- this.admin_v1 = new Admin_Module_V1(typeof admin_config === 'object' ? admin_config : {});
149
- this.admin_v1.init(this);
157
+ this.admin_v1 = new Admin_Module_V1(typeof admin_config === 'object' ? admin_config : {});
158
+ this.admin_v1.init(this);
150
159
 
151
- // Register Admin V1 Page Route
152
- let Admin_Shell_Control;
153
- try {
154
- Admin_Shell_Control = require('./admin-ui/v1/client').controls.Admin_Shell;
155
- } catch (e) {
156
- console.warn('Failed to load Admin_Shell control:', e.message || e);
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
- if (Admin_Shell_Control) {
160
- const admin_v1_app = new Webpage({
161
- content: Admin_Shell_Control,
162
- title: 'jsgui3 Admin Dashboard'
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
- const admin_v1_publisher = new HTTP_Webpage_Publisher({
166
- name: 'Admin_V1_Publisher',
167
- webpage: admin_v1_app,
168
- src_path_client_js: lib_path.join(__dirname, 'admin-ui/v1/client.js')
169
- });
170
- admin_v1_publisher.name = 'Admin_V1_Publisher';
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
- const is_dev_defaults = !process.env.ADMIN_V1_PASSWORD && process.env.NODE_ENV !== 'production';
173
- const login_hint = is_dev_defaults
174
- ? '<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>'
175
- : '<div class="hint">Sign in with your configured credentials.</div>';
176
- const login_html = `<!doctype html>
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
- const serve_admin_v1_page = (req, res) => {
248
- if (req && typeof req.url === 'string' && req.url.startsWith('/admin/v1/login')) {
249
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
250
- res.end(login_html);
251
- return;
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
- server_router.set_route('/admin/v1/login', null, (req, res) => {
269
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
270
- res.end(login_html);
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
- // Namespace admin v1 assets under /admin/v1/ to avoid
274
- // colliding with the main app's /js/js.js and /css/css.css.
275
- let admin_v1_cached_html = null;
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
- admin_v1_publisher.on('ready', (res_ready) => {
278
- if (res_ready && res_ready._arr) {
279
- for (const bundle_item of res_ready._arr) {
280
- const { type } = bundle_item;
281
- if (type === 'HTML') {
282
- // Rewrite asset references so browser fetches
283
- // the admin-specific JS/CSS, not the main app's.
284
- let html = bundle_item.text || '';
285
- html = html.replace(
286
- /href="\/css\/css\.css"/g,
287
- 'href="/admin/v1/css/css.css"'
288
- );
289
- html = html.replace(
290
- /src="\/js\/js\.js"/g,
291
- 'src="/admin/v1/js/js.js"'
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
- /<head([^>]*)>/i,
297
- '<head$1><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">'
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
- // Serve authenticated admin page at /admin/v1
313
- server_router.set_route('/admin/v1', null, serve_admin_v1_page);
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
- // Temporary handler until the publisher finishes bundling
318
- server_router.set_route('/admin/v1', null, serve_admin_v1_page);
319
- resource_pool.add(admin_v1_publisher);
320
- } else {
321
- console.warn('Skipping /admin/v1 route registration due to missing Admin_Shell control.');
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
- publish(name, fn) {
454
- // Get the function publisher.
455
- // Possibly ensure it exists.
456
- //const fn_publisher = this.function_publisher;
457
- //fn_publisher.add(name, fn);
458
- const fpub = new HTTP_Function_Publisher({ name, fn });
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
- // Auto-prefix /api/ for simple names
464
- // If name already starts with '/', use as-is for full route control
465
- const full_route = name.startsWith('/') ? name : '/api/' + name;
466
- this.server_router.set_route(full_route, fpub, fpub.handle_http);
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
- callback('No allowed network interfaces found.');
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
- let outcome;
581
- try {
582
- outcome = server_router.process(req, res);
583
- } catch (err) {
584
- respond_error(res, err);
585
- return;
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
- return;
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
- } else if (outcome === false && !res.writableEnded) {
604
- respond_not_found(res);
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;