jsgui3-server 0.0.151 → 0.0.155

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 (109) hide show
  1. package/README.md +21 -0
  2. package/admin-ui/v1/controls/admin_shell.js +33 -0
  3. package/admin-ui/v1/server.js +14 -1
  4. package/docs/agi/skills/README.md +23 -0
  5. package/docs/agi/skills/agent-output-control/SKILL.md +56 -0
  6. package/docs/agi/skills/ai-deep-research/SKILL.md +52 -0
  7. package/docs/agi/skills/autonomous-ui-inspection/SKILL.md +102 -0
  8. package/docs/agi/skills/deep-research/SKILL.md +156 -0
  9. package/docs/agi/skills/endurance/SKILL.md +53 -0
  10. package/docs/agi/skills/exploring-other-codebases/SKILL.md +56 -0
  11. package/docs/agi/skills/instruction-adherence/SKILL.md +73 -0
  12. package/docs/agi/skills/jsgui3-activation-debug/SKILL.md +94 -0
  13. package/docs/agi/skills/jsgui3-context-menu-patterns/SKILL.md +94 -0
  14. package/docs/agi/skills/puppeteer-efficient-ui-verification/SKILL.md +65 -0
  15. package/docs/agi/skills/runaway-process-guard/SKILL.md +49 -0
  16. package/docs/agi/skills/session-discipline/SKILL.md +40 -0
  17. package/docs/agi/skills/skill-writing/SKILL.md +211 -0
  18. package/docs/agi/skills/static-analysis/SKILL.md +58 -0
  19. package/docs/agi/skills/targeted-testing/SKILL.md +63 -0
  20. package/docs/agi/skills/understanding-jsgui3/SKILL.md +85 -0
  21. package/docs/api-reference.md +120 -2
  22. package/docs/books/jsgui3-bundling-research-book/06-unused-module-elimination-strategy.md +1 -0
  23. package/docs/books/jsgui3-bundling-research-book/07-jsgui3-html-control-and-mixin-pruning.md +33 -0
  24. package/docs/books/website-design/01-introduction.md +73 -0
  25. package/docs/books/website-design/02-current-state.md +195 -0
  26. package/docs/books/website-design/03-base-class.md +181 -0
  27. package/docs/books/website-design/04-webpage.md +307 -0
  28. package/docs/books/website-design/05-website.md +456 -0
  29. package/docs/books/website-design/06-pages-storage.md +170 -0
  30. package/docs/books/website-design/07-api-layer.md +285 -0
  31. package/docs/books/website-design/08-server-integration.md +271 -0
  32. package/docs/books/website-design/09-cross-agent-review.md +190 -0
  33. package/docs/books/website-design/10-open-questions.md +196 -0
  34. package/docs/books/website-design/11-converged-recommendation.md +205 -0
  35. package/docs/books/website-design/12-content-model.md +395 -0
  36. package/docs/books/website-design/13-webpage-module-spec.md +404 -0
  37. package/docs/books/website-design/14-website-module-spec.md +541 -0
  38. package/docs/books/website-design/15-multi-repo-plan.md +275 -0
  39. package/docs/books/website-design/16-minimal-first.md +203 -0
  40. package/docs/books/website-design/17-implementation-report-codex.md +81 -0
  41. package/docs/books/website-design/README.md +43 -0
  42. package/docs/bundling-system-deep-dive.md +112 -3
  43. package/docs/configuration-reference.md +84 -0
  44. package/docs/proposals/jsgui3-website-and-webpage-design-jsgui3-server-support.md +257 -0
  45. package/docs/proposals/jsgui3-website-and-webpage-design-review.md +73 -0
  46. package/docs/proposals/jsgui3-website-and-webpage-design.md +732 -0
  47. package/docs/swagger.md +316 -0
  48. package/examples/controls/1) window/server.js +6 -1
  49. package/examples/controls/21) mvvm and declarative api/check.js +94 -0
  50. package/examples/controls/21) mvvm and declarative api/check_output.txt +25 -0
  51. package/examples/controls/21) mvvm and declarative api/check_output_2.txt +27 -0
  52. package/examples/controls/21) mvvm and declarative api/client.js +241 -0
  53. declarative api/e2e-screenshot-1-name-change.png +0 -0
  54. declarative api/e2e-screenshot-2-toggled.png +0 -0
  55. declarative api/e2e-screenshot-3-final.png +0 -0
  56. declarative api/e2e-screenshot-final.png +0 -0
  57. package/examples/controls/21) mvvm and declarative api/e2e-test.js +175 -0
  58. package/examples/controls/21) mvvm and declarative api/out.html +1 -0
  59. package/examples/controls/21) mvvm and declarative api/page_out.html +1 -0
  60. package/examples/controls/21) mvvm and declarative api/server.js +18 -0
  61. package/examples/data-views/01) query-endpoint/server.js +61 -0
  62. package/labs/website-design/001-base-class-overhead/check.js +162 -0
  63. package/labs/website-design/002-pages-storage/check.js +244 -0
  64. package/labs/website-design/002-pages-storage/results.txt +0 -0
  65. package/labs/website-design/003-type-detection/check.js +193 -0
  66. package/labs/website-design/003-type-detection/results.txt +0 -0
  67. package/labs/website-design/004-two-stage-validation/check.js +314 -0
  68. package/labs/website-design/004-two-stage-validation/results.txt +0 -0
  69. package/labs/website-design/005-normalize-input/check.js +303 -0
  70. package/labs/website-design/006-serve-website-spike/check.js +290 -0
  71. package/labs/website-design/README.md +34 -0
  72. package/labs/website-design/manifest.json +68 -0
  73. package/labs/website-design/run-all.js +60 -0
  74. package/middleware/json-body.js +126 -0
  75. package/openapi.js +474 -0
  76. package/package.json +13 -7
  77. package/publishers/Publishers.js +6 -5
  78. package/publishers/http-function-publisher.js +135 -126
  79. package/publishers/http-webpage-publisher.js +89 -11
  80. package/publishers/query-publisher.js +116 -0
  81. package/publishers/swagger-publisher.js +203 -0
  82. package/publishers/swagger-ui.js +578 -0
  83. package/resources/adapters/array-adapter.js +143 -0
  84. package/resources/processors/bundlers/js/esbuild/Advanced_JS_Bundler_Using_ESBuild.js +90 -22
  85. package/resources/processors/bundlers/js/esbuild/Core_JS_Non_Minifying_Bundler_Using_ESBuild.js +50 -14
  86. package/resources/processors/bundlers/js/esbuild/Core_JS_Single_File_Minifying_Bundler_Using_ESBuild.js +48 -14
  87. package/resources/processors/bundlers/js/esbuild/JSGUI3_HTML_Control_Optimizer.js +396 -44
  88. package/resources/query-resource.js +131 -0
  89. package/serve-factory.js +677 -18
  90. package/server.js +585 -167
  91. package/tests/README.md +86 -2
  92. package/tests/admin-ui-jsgui-controls.test.js +16 -1
  93. package/tests/bundling-default-control-elimination.puppeteer.test.js +32 -1
  94. package/tests/control-elimination-root-feature-pruning.test.js +440 -0
  95. package/tests/control-elimination-static-bracket-access.test.js +245 -0
  96. package/tests/control-scan-manifest-regression.test.js +2 -0
  97. package/tests/end-to-end.test.js +22 -21
  98. package/tests/fixtures/control_scan_manifest_expectations.json +4 -2
  99. package/tests/helpers/playwright-e2e-harness.js +326 -0
  100. package/tests/helpers/puppeteer-e2e-harness.js +62 -1
  101. package/tests/openapi.test.js +319 -0
  102. package/tests/playwright-smoke.test.js +134 -0
  103. package/tests/project-local-controls-bundling.puppeteer.test.js +462 -0
  104. package/tests/publish-enhancements.test.js +673 -0
  105. package/tests/query-publisher.test.js +430 -0
  106. package/tests/quick-json-body-test.js +169 -0
  107. package/tests/serve.test.js +425 -122
  108. package/tests/swagger-publisher.test.js +1076 -0
  109. package/tests/test-runner.js +4 -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;
@@ -151,35 +154,35 @@ class JSGUI_Single_Process_Server extends Evented_Class {
151
154
 
152
155
  const Admin_Module_V1 = require('./admin-ui/v1/server');
153
156
  if (admin_enabled) {
154
- this.admin_v1 = new Admin_Module_V1(typeof admin_config === 'object' ? admin_config : {});
155
- 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);
156
159
 
157
- // Register Admin V1 Page Route
158
- let Admin_Shell_Control;
159
- try {
160
- Admin_Shell_Control = require('./admin-ui/v1/client').controls.Admin_Shell;
161
- } catch (e) {
162
- console.warn('Failed to load Admin_Shell control:', e.message || e);
163
- }
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
+ }
164
167
 
165
- if (Admin_Shell_Control) {
166
- const admin_v1_app = new Webpage({
167
- content: Admin_Shell_Control,
168
- title: 'jsgui3 Admin Dashboard'
169
- });
168
+ if (Admin_Shell_Control) {
169
+ const admin_v1_app = new Webpage({
170
+ content: Admin_Shell_Control,
171
+ title: 'jsgui3 Admin Dashboard'
172
+ });
170
173
 
171
- const admin_v1_publisher = new HTTP_Webpage_Publisher({
172
- name: 'Admin_V1_Publisher',
173
- webpage: admin_v1_app,
174
- src_path_client_js: lib_path.join(__dirname, 'admin-ui/v1/client.js')
175
- });
176
- 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';
177
180
 
178
- const is_dev_defaults = !process.env.ADMIN_V1_PASSWORD && process.env.NODE_ENV !== 'production';
179
- const login_hint = is_dev_defaults
180
- ? '<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>'
181
- : '<div class="hint">Sign in with your configured credentials.</div>';
182
- 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>
183
186
  <html>
184
187
  <head>
185
188
  <meta charset="utf-8" />
@@ -250,82 +253,82 @@ class JSGUI_Single_Process_Server extends Evented_Class {
250
253
  </body>
251
254
  </html>`;
252
255
 
253
- const serve_admin_v1_page = (req, res) => {
254
- if (req && typeof req.url === 'string' && req.url.startsWith('/admin/v1/login')) {
255
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
256
- res.end(login_html);
257
- return;
258
- }
259
-
260
- if (!this.admin_v1 || !this.admin_v1.is_admin_read_request(req)) {
261
- res.writeHead(302, { Location: '/admin/v1/login' });
262
- res.end();
263
- return;
264
- }
265
- if (admin_v1_cached_html) {
266
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
267
- res.end(admin_v1_cached_html);
268
- } else {
269
- res.writeHead(503, { 'Content-Type': 'text/plain' });
270
- res.end('Admin UI v1 is loading, please retry...');
271
- }
272
- };
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
+ }
273
262
 
274
- server_router.set_route('/admin/v1/login', null, (req, res) => {
275
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
276
- res.end(login_html);
277
- });
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
+ };
278
276
 
279
- // Namespace admin v1 assets under /admin/v1/ to avoid
280
- // colliding with the main app's /js/js.js and /css/css.css.
281
- 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
+ });
282
281
 
283
- admin_v1_publisher.on('ready', (res_ready) => {
284
- if (res_ready && res_ready._arr) {
285
- for (const bundle_item of res_ready._arr) {
286
- const { type } = bundle_item;
287
- if (type === 'HTML') {
288
- // Rewrite asset references so browser fetches
289
- // the admin-specific JS/CSS, not the main app's.
290
- let html = bundle_item.text || '';
291
- html = html.replace(
292
- /href="\/css\/css\.css"/g,
293
- 'href="/admin/v1/css/css.css"'
294
- );
295
- html = html.replace(
296
- /src="\/js\/js\.js"/g,
297
- 'src="/admin/v1/js/js.js"'
298
- );
299
- // Inject viewport meta for mobile rendering
300
- 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
+ );
301
298
  html = html.replace(
302
- /<head([^>]*)>/i,
303
- '<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"'
304
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);
305
318
  }
306
- admin_v1_cached_html = html;
307
- } else {
308
- // JS → /admin/v1/js/js.js, CSS → /admin/v1/css/css.css
309
- const namespaced_route =
310
- type === 'JavaScript' ? '/admin/v1/js/js.js' :
311
- type === 'CSS' ? '/admin/v1/css/css.css' :
312
- '/admin/v1' + bundle_item.route;
313
- const responder = new Static_Route_HTTP_Responder(bundle_item);
314
- server_router.set_route(namespaced_route, responder, responder.handle_http);
315
319
  }
316
- }
317
320
 
318
- // Serve authenticated admin page at /admin/v1
319
- server_router.set_route('/admin/v1', null, serve_admin_v1_page);
320
- }
321
- });
321
+ // Serve authenticated admin page at /admin/v1
322
+ server_router.set_route('/admin/v1', null, serve_admin_v1_page);
323
+ }
324
+ });
322
325
 
323
- // Temporary handler until the publisher finishes bundling
324
- server_router.set_route('/admin/v1', null, serve_admin_v1_page);
325
- resource_pool.add(admin_v1_publisher);
326
- } else {
327
- console.warn('Skipping /admin/v1 route registration due to missing Admin_Shell control.');
328
- }
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
+ }
329
332
  } else {
330
333
  // admin_enabled === false
331
334
  this.admin_v1 = null;
@@ -371,10 +374,11 @@ class JSGUI_Single_Process_Server extends Evented_Class {
371
374
  // Specific options for when that publisher is in debug mode.
372
375
 
373
376
 
374
- console.log('waiting for wp_publisher ready');
375
- wp_publisher.on('ready', (wp_ready_res) => {
376
- //console.log('wp publisher is ready');
377
- if (wp_ready_res._arr) {
377
+ console.log('waiting for wp_publisher ready');
378
+ wp_publisher.on('ready', (wp_ready_res) => {
379
+ this.latest_wp_bundle = wp_ready_res;
380
+ //console.log('wp publisher is ready');
381
+ if (wp_ready_res._arr) {
378
382
 
379
383
 
380
384
  for (const bundle_item of wp_ready_res._arr) {
@@ -456,20 +460,196 @@ class JSGUI_Single_Process_Server extends Evented_Class {
456
460
  Object.defineProperty(this, 'router', { get: () => server_router })
457
461
  }
458
462
 
459
- publish(name, fn) {
460
- // Get the function publisher.
461
- // Possibly ensure it exists.
462
- //const fn_publisher = this.function_publisher;
463
- //fn_publisher.add(name, fn);
464
- const fpub = new HTTP_Function_Publisher({ name, fn });
463
+ /**
464
+ * Publish a JavaScript function as an HTTP API endpoint.
465
+ *
466
+ * The function is wrapped in a {@link Function_Publisher} and
467
+ * registered with the server's router. An entry is also added to
468
+ * `this._api_registry` so the OpenAPI spec generator can discover it.
469
+ *
470
+ * ### Route resolution
471
+ *
472
+ * - If `name` starts with `'/'`, it is used as-is (e.g. `'/health'`).
473
+ * - Otherwise it is auto-prefixed with `'/api/'` (e.g. `'users/list'` → `'/api/users/list'`).
474
+ *
475
+ * ### Metadata for Swagger
476
+ *
477
+ * The `meta` argument feeds the OpenAPI spec generator. All fields
478
+ * are optional — endpoints without metadata still work but produce
479
+ * a minimal Swagger entry.
480
+ *
481
+ * | Field | Type | Purpose |
482
+ * |---------------------|----------|----------------------------------------------|
483
+ * | `meta.method` | string | HTTP method (`'GET'`, `'POST'`, etc.) |
484
+ * | `meta.summary` | string | One-line summary displayed in Swagger UI |
485
+ * | `meta.description` | string | Multi-line Markdown description |
486
+ * | `meta.tags` | string[] | Grouping tags in Swagger UI |
487
+ * | `meta.params` | Object | Request body schema (`{key: {type, ...}}`) |
488
+ * | `meta.returns` | Object | Response body schema (`{key: {type, ...}}`) |
489
+ * | `meta.deprecated` | boolean | Mark endpoint as deprecated |
490
+ * | `meta.operationId` | string | Custom OpenAPI operationId |
491
+ * | `meta.raw` | boolean | Raw `(req, res)` handler — skip Function_Publisher |
492
+ *
493
+ * @param {string} name - Endpoint name or route path.
494
+ * @param {Function} fn - Handler function `(input) => result`, or `(req, res)` when `meta.raw` is `true`.
495
+ * @param {Object} [meta={}] - API metadata for routing and Swagger.
496
+ *
497
+ * @example
498
+ * // Minimal — just a name and function:
499
+ * server.publish('ping', () => ({ pong: true }));
500
+ * // → POST /api/ping
501
+ *
502
+ * @example
503
+ * // With full metadata for Swagger:
504
+ * server.publish('users/list', listUsers, {
505
+ * method: 'POST',
506
+ * summary: 'List all users',
507
+ * description: 'Returns paginated user list with filtering.',
508
+ * tags: ['Users'],
509
+ * params: {
510
+ * page: { type: 'integer', description: 'Page number', default: 1 },
511
+ * page_size: { type: 'integer', description: 'Items per page', default: 25 }
512
+ * },
513
+ * returns: {
514
+ * rows: { type: 'array', items: { type: 'object' } },
515
+ * total_count: { type: 'integer' }
516
+ * }
517
+ * });
518
+ * // → POST /api/users/list (documented in Swagger UI)
519
+ *
520
+ * @example
521
+ * // Raw handler for streaming NDJSON:
522
+ * server.publish('events/stream', (req, res) => {
523
+ * res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
524
+ * res.write(JSON.stringify({ event: 'start' }) + '\n');
525
+ * res.end();
526
+ * }, { method: 'GET', raw: true, summary: 'Stream events' });
527
+ */
528
+ publish(name, fn, meta = {}) {
529
+ // Auto-prefix /api/ for simple names.
530
+ // If name already starts with '/', use as-is for full route control.
531
+ const full_route = name.startsWith('/') ? name : '/api/' + name;
532
+
533
+ // ── Raw handler passthrough ──
534
+ // When meta.raw is true, fn receives (req, res) directly.
535
+ // Useful for streaming, SSE, gzip, or custom response control.
536
+ if (meta.raw) {
537
+ this._api_registry = this._api_registry || [];
538
+ this._api_registry.push({
539
+ path: full_route,
540
+ method: (meta.method && meta.method !== 'ANY') ? meta.method.toUpperCase() : 'GET',
541
+ meta,
542
+ schema: {}
543
+ });
544
+
545
+ if (meta.method && meta.method !== 'ANY') {
546
+ const method = meta.method.toUpperCase();
547
+ this.server_router.set_route(full_route, null, (req, res) => {
548
+ if (req.method.toUpperCase() !== method && req.method.toUpperCase() !== 'HEAD') {
549
+ res.writeHead(405, { 'Allow': method });
550
+ res.end('Method Not Allowed');
551
+ return;
552
+ }
553
+ return fn(req, res);
554
+ });
555
+ } else {
556
+ this.server_router.set_route(full_route, null, fn);
557
+ }
558
+ return;
559
+ }
560
+
561
+ // ── Standard function publisher ──
562
+ const fpub = new HTTP_Function_Publisher({ name, fn, meta });
465
563
 
466
564
  this.function_publishers = this.function_publishers || [];
467
565
  this.function_publishers.push(fpub);
468
566
 
469
- // Auto-prefix /api/ for simple names
470
- // If name already starts with '/', use as-is for full route control
471
- const full_route = name.startsWith('/') ? name : '/api/' + name;
472
- this.server_router.set_route(full_route, fpub, fpub.handle_http);
567
+ // Register in API registry for OpenAPI spec generation.
568
+ this._api_registry = this._api_registry || [];
569
+ this._api_registry.push({
570
+ path: full_route,
571
+ method: (meta.method && meta.method !== 'ANY') ? meta.method.toUpperCase() : 'POST',
572
+ meta,
573
+ schema: fpub.schema
574
+ });
575
+
576
+ if (meta.method && meta.method !== 'ANY') {
577
+ const method = meta.method.toUpperCase();
578
+ this.server_router.set_route(full_route, fpub, (req, res) => {
579
+ if (req.method.toUpperCase() !== method) {
580
+ res.writeHead(405, { 'Allow': method });
581
+ res.end('Method Not Allowed');
582
+ return;
583
+ }
584
+ return fpub.handle_http(req, res);
585
+ });
586
+ } else {
587
+ this.server_router.set_route(full_route, fpub, fpub.handle_http);
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Register the built-in Swagger / OpenAPI routes on this server.
593
+ *
594
+ * Creates two endpoints:
595
+ *
596
+ * | Route | Method | Content-Type | Purpose |
597
+ * |----------------------|--------|--------------------|----------------------------------|
598
+ * | `/api/openapi.json` | GET | `application/json` | OpenAPI 3.0.3 spec (JSON) |
599
+ * | `/api/docs` | GET | `text/html` | Interactive Swagger UI page |
600
+ *
601
+ * The Swagger UI page loads its JS and CSS from the unpkg CDN at
602
+ * runtime, so zero npm dependencies are required. The page is
603
+ * styled to match jsgui3's dark aesthetic.
604
+ *
605
+ * ### Automatic registration
606
+ *
607
+ * When using `Server.serve()`, this method is called automatically
608
+ * after all endpoints are registered — unless `swagger: false` is
609
+ * set in the options. The default is:
610
+ *
611
+ * - **Development** (`NODE_ENV !== 'production'`): Swagger enabled.
612
+ * - **Production** (`NODE_ENV === 'production'`): Swagger disabled.
613
+ *
614
+ * ### Manual registration
615
+ *
616
+ * For servers created directly via `new Server(...)`, call this
617
+ * method after all `publish()` calls:
618
+ *
619
+ * ```js
620
+ * server._register_swagger_routes({ title: 'My API', version: '2.0.0' });
621
+ * ```
622
+ *
623
+ * This method is idempotent — calling it multiple times has no
624
+ * effect after the first call.
625
+ *
626
+ * @param {Object} [options] - Override options passed to the spec generator.
627
+ * @param {string} [options.title] - Override API title (default: server.name).
628
+ * @param {string} [options.version] - Override API version (default: '1.0.0').
629
+ * @param {string} [options.description] - Override API description.
630
+ */
631
+ _register_swagger_routes(options = {}) {
632
+ if (this._swagger_registered) return;
633
+ this._swagger_registered = true;
634
+
635
+ const Swagger_Publisher = require('./publishers/swagger-publisher');
636
+
637
+ const swagger_pub = new Swagger_Publisher({
638
+ server: this,
639
+ title: options.title || this.name || 'API Documentation',
640
+ version: options.version,
641
+ description: options.description
642
+ });
643
+
644
+ /**
645
+ * The Swagger_Publisher instance, stored for introspection.
646
+ * @type {Swagger_Publisher}
647
+ */
648
+ this._swagger_publisher = swagger_pub;
649
+
650
+ // Register both routes pointing to the same publisher.
651
+ this.server_router.set_route('/api/openapi.json', swagger_pub, swagger_pub.handle_http.bind(swagger_pub));
652
+ this.server_router.set_route('/api/docs', swagger_pub, swagger_pub.handle_http.bind(swagger_pub));
473
653
  }
474
654
 
475
655
  publish_observable(route, obs, options = {}) {
@@ -513,7 +693,71 @@ class JSGUI_Single_Process_Server extends Evented_Class {
513
693
  get resource_names() {
514
694
  return this.resource_pool.resource_names;
515
695
  }
696
+
697
+ get_listening_endpoints() {
698
+ if (!this.listening_endpoints || !this.listening_endpoints.length) {
699
+ return [];
700
+ }
701
+ return this.listening_endpoints.map(endpoint => ({ ...endpoint }));
702
+ }
703
+
704
+ get_primary_endpoint() {
705
+ const endpoints = this.get_listening_endpoints();
706
+ if (!endpoints.length) return null;
707
+ return endpoints[0].url;
708
+ }
709
+
710
+ get_startup_diagnostics() {
711
+ if (!this.startup_diagnostics) {
712
+ return null;
713
+ }
714
+ return {
715
+ ...this.startup_diagnostics,
716
+ addresses_attempted: Array.isArray(this.startup_diagnostics.addresses_attempted)
717
+ ? [...this.startup_diagnostics.addresses_attempted]
718
+ : [],
719
+ errors_by_address: this.startup_diagnostics.errors_by_address
720
+ ? { ...this.startup_diagnostics.errors_by_address }
721
+ : {}
722
+ };
723
+ }
724
+
725
+ print_endpoints(options = {}) {
726
+ const endpoints = this.get_listening_endpoints();
727
+ const logger = typeof options.logger === 'function' ? options.logger : console.log;
728
+ const include_index = !!options.include_index;
729
+ const prefix = typeof options.prefix === 'string' ? options.prefix : 'listening endpoint';
730
+
731
+ if (!endpoints.length) {
732
+ logger('no listening endpoints');
733
+ return [];
734
+ }
735
+
736
+ const lines = endpoints.map((endpoint, index) => {
737
+ if (include_index) {
738
+ return `${prefix} [${index}]: ${endpoint.url}`;
739
+ }
740
+ return `${prefix}: ${endpoint.url}`;
741
+ });
742
+
743
+ lines.forEach((line) => logger(line));
744
+ return lines;
745
+ }
746
+
747
+ _record_listening_endpoint(protocol, host, port) {
748
+ this.listening_endpoints = this.listening_endpoints || [];
749
+ this.listening_endpoints.push({
750
+ protocol,
751
+ host,
752
+ port,
753
+ url: `${protocol}://${host}:${port}/`
754
+ });
755
+ }
756
+
516
757
  'start'(port, callback, fnProcessRequest) {
758
+ const start_options = (fnProcessRequest && typeof fnProcessRequest === 'object') ? fnProcessRequest : {};
759
+ const fallback_on_port_conflict = start_options.on_port_conflict === 'auto-loopback';
760
+
517
761
  // Guard against double-start which causes EADDRINUSE
518
762
  if (this._started) {
519
763
  console.warn('Server.start() called but server already started. Ignoring duplicate call.');
@@ -521,6 +765,7 @@ class JSGUI_Single_Process_Server extends Evented_Class {
521
765
  return;
522
766
  }
523
767
  this._started = true;
768
+ this.listening_endpoints = [];
524
769
 
525
770
  if (tof(port) !== 'number') {
526
771
  console.log('Invalid port:', port);
@@ -534,12 +779,14 @@ class JSGUI_Single_Process_Server extends Evented_Class {
534
779
  this.raise('starting');
535
780
  rp.start(err => {
536
781
  if (err) {
782
+ this._started = false;
537
783
  throw err;
538
784
  } else {
539
785
  const lsi = rp.get_resource('Local Server Info');
540
786
  const server_router = rp.get_resource('Server Router');
541
787
  lsi.getters.net((err, net) => {
542
788
  if (err) {
789
+ this._started = false;
543
790
  callback(err);
544
791
  } else {
545
792
  // NEW: Filter addresses by allowed_addresses if specified.
@@ -559,11 +806,47 @@ class JSGUI_Single_Process_Server extends Evented_Class {
559
806
  let num_to_start = arr_ipv4_addresses.length;
560
807
  let started_count = 0;
561
808
  let last_error = null;
809
+ const errors_by_address = {};
562
810
  let ready_raised = false;
811
+ let fallback_attempted = false;
563
812
  if (num_to_start === 0) {
564
- callback('No allowed network interfaces found.');
813
+ const no_interface_error = new Error('No allowed network interfaces found.');
814
+ no_interface_error.code = 'ENOINTERFACES';
815
+ this._started = false;
816
+ if (callback) callback(no_interface_error);
565
817
  return;
566
818
  }
819
+
820
+ const start_loopback_fallback = async (process_request) => {
821
+ const fallback_host = '127.0.0.1';
822
+ const fallback_port = await get_port_or_free(0, fallback_host);
823
+ const fallback_protocol = this.https_options ? 'https' : 'http';
824
+ const fallback_server = this.https_options
825
+ ? https.createServer(this.https_options, (req, res) => process_request(req, res))
826
+ : http.createServer((req, res) => process_request(req, res));
827
+ this.http_servers.push(fallback_server);
828
+ fallback_server.timeout = 10800000;
829
+ await new Promise((resolve, reject) => {
830
+ fallback_server.once('error', reject);
831
+ fallback_server.listen(fallback_port, fallback_host, resolve);
832
+ });
833
+ this._record_listening_endpoint(fallback_protocol, fallback_host, fallback_port);
834
+ if (!ready_raised) {
835
+ console.warn(`[server.start] Port conflict fallback engaged. Listening on ${fallback_host}:${fallback_port}`);
836
+ this.raise('listening');
837
+ ready_raised = true;
838
+ }
839
+ this.port = fallback_port;
840
+ this.startup_diagnostics = {
841
+ requested_port: port,
842
+ fallback_port,
843
+ fallback_host,
844
+ addresses_attempted: arr_ipv4_addresses,
845
+ errors_by_address
846
+ };
847
+ if (callback) callback(null, true);
848
+ };
849
+
567
850
  const finalize_start = (err) => {
568
851
  if (num_to_start !== 0) return;
569
852
  if (started_count > 0) {
@@ -572,10 +855,32 @@ class JSGUI_Single_Process_Server extends Evented_Class {
572
855
  this.raise('listening'); // Changed from 'ready' to avoid double-fire
573
856
  ready_raised = true;
574
857
  }
858
+ this.port = port;
859
+ this.startup_diagnostics = {
860
+ requested_port: port,
861
+ addresses_attempted: arr_ipv4_addresses,
862
+ errors_by_address
863
+ };
575
864
  if (callback) callback(null, true);
576
865
  return;
577
866
  }
578
867
  const final_error = err || last_error || new Error('No servers started.');
868
+ final_error.startup_diagnostics = {
869
+ requested_port: port,
870
+ addresses_attempted: arr_ipv4_addresses,
871
+ errors_by_address
872
+ };
873
+
874
+ if (fallback_on_port_conflict && !fallback_attempted && final_error && final_error.code === 'EADDRINUSE') {
875
+ fallback_attempted = true;
876
+ start_loopback_fallback(process_request).catch((fallback_err) => {
877
+ this._started = false;
878
+ if (callback) callback(fallback_err);
879
+ });
880
+ return;
881
+ }
882
+
883
+ this._started = false;
579
884
  if (callback) callback(final_error);
580
885
  };
581
886
  const respond_not_found = (res) => {
@@ -662,15 +967,27 @@ class JSGUI_Single_Process_Server extends Evented_Class {
662
967
  next();
663
968
  };
664
969
 
665
- if (this.https_options) {
666
- each(arr_ipv4_addresses, (ipv4_address) => {
667
- try {
668
- var https_server = https.createServer(this.https_options, function (req, res) {
669
- process_request(req, res);
670
- });
671
- this.http_servers.push(https_server);
672
- https_server.on('error', (err) => {
673
- last_error = err;
970
+ if (this.https_options) {
971
+ each(arr_ipv4_addresses, (ipv4_address) => {
972
+ try {
973
+ var https_server = https.createServer(this.https_options, function (req, res) {
974
+ process_request(req, res);
975
+ });
976
+ const open_sockets = new Set();
977
+ https_server._open_sockets = open_sockets;
978
+ https_server.on('connection', (socket) => {
979
+ open_sockets.add(socket);
980
+ socket.on('close', () => {
981
+ open_sockets.delete(socket);
982
+ });
983
+ });
984
+ this.http_servers.push(https_server);
985
+ https_server.on('error', (err) => {
986
+ last_error = err;
987
+ errors_by_address[ipv4_address] = {
988
+ code: err.code,
989
+ message: err.message
990
+ };
674
991
  if (err.code === 'EACCES') {
675
992
  console.error('Permission denied:', err.message);
676
993
  } else if (err.code === 'EADDRINUSE') {
@@ -683,6 +1000,7 @@ class JSGUI_Single_Process_Server extends Evented_Class {
683
1000
  });
684
1001
  https_server.timeout = 10800000;
685
1002
  https_server.listen(port, ipv4_address, () => {
1003
+ this._record_listening_endpoint('https', ipv4_address, port);
686
1004
  console.log('* Server running at https://' + ipv4_address + ':' + port + '/');
687
1005
  started_count++;
688
1006
  num_to_start--;
@@ -694,15 +1012,27 @@ class JSGUI_Single_Process_Server extends Evented_Class {
694
1012
  finalize_start(err);
695
1013
  }
696
1014
  });
697
- } else {
698
- each(arr_ipv4_addresses, (ipv4_address) => {
699
- try {
700
- var http_server = http.createServer(function (req, res) {
701
- process_request(req, res);
702
- });
703
- this.http_servers.push(http_server);
704
- http_server.on('error', (err) => {
705
- last_error = err;
1015
+ } else {
1016
+ each(arr_ipv4_addresses, (ipv4_address) => {
1017
+ try {
1018
+ var http_server = http.createServer(function (req, res) {
1019
+ process_request(req, res);
1020
+ });
1021
+ const open_sockets = new Set();
1022
+ http_server._open_sockets = open_sockets;
1023
+ http_server.on('connection', (socket) => {
1024
+ open_sockets.add(socket);
1025
+ socket.on('close', () => {
1026
+ open_sockets.delete(socket);
1027
+ });
1028
+ });
1029
+ this.http_servers.push(http_server);
1030
+ http_server.on('error', (err) => {
1031
+ last_error = err;
1032
+ errors_by_address[ipv4_address] = {
1033
+ code: err.code,
1034
+ message: err.message
1035
+ };
706
1036
  if (err.code === 'EACCES') {
707
1037
  console.error('Permission denied:', err.message);
708
1038
  } else if (err.code === 'EADDRINUSE') {
@@ -715,6 +1045,7 @@ class JSGUI_Single_Process_Server extends Evented_Class {
715
1045
  });
716
1046
  http_server.timeout = 10800000;
717
1047
  http_server.listen(port, ipv4_address, () => {
1048
+ this._record_listening_endpoint('http', ipv4_address, port);
718
1049
  console.log('* Server running at http://' + ipv4_address + ':' + port + '/');
719
1050
  started_count++;
720
1051
  num_to_start--;
@@ -733,48 +1064,135 @@ class JSGUI_Single_Process_Server extends Evented_Class {
733
1064
  });
734
1065
  }
735
1066
 
736
- close(callback) {
737
- const invoke_stop = (target, done) => {
738
- if (!target || typeof target.stop !== 'function') {
739
- done(null);
740
- return;
741
- }
742
-
743
- if (target.stop.length >= 1) {
744
- target.stop((error) => done(error || null));
745
- return;
746
- }
747
-
748
- try {
749
- const stop_result = target.stop();
750
- if (stop_result && typeof stop_result.then === 'function') {
751
- stop_result.then(() => done(null), (error) => done(error || null));
752
- return;
753
- }
754
- done(null);
755
- } catch (error) {
756
- done(error);
757
- }
758
- };
759
-
760
- const close_http_servers = (done) => {
761
- let count = this.http_servers.length;
762
- if (count === 0) {
763
- this.http_servers = [];
764
- done();
765
- return;
766
- }
767
-
768
- this.http_servers.forEach(server => {
769
- server.close(() => {
770
- count--;
771
- if (count === 0) {
772
- this.http_servers = [];
773
- done();
774
- }
775
- });
776
- });
777
- };
1067
+ close(callback) {
1068
+ const invoke_stop = (target, done) => {
1069
+ const stop_timeout_ms = 5000;
1070
+ let did_finish = false;
1071
+ const stop_timeout_handle = setTimeout(() => {
1072
+ const target_name = (target && (target.name || target.__type_name || (target.constructor && target.constructor.name)))
1073
+ || 'unknown_target';
1074
+ console.warn(`Timed out waiting for stop() on ${target_name}; continuing shutdown.`);
1075
+ finish_stop(null);
1076
+ }, stop_timeout_ms);
1077
+ stop_timeout_handle.unref?.();
1078
+
1079
+ const finish_stop = (error) => {
1080
+ if (did_finish) {
1081
+ return;
1082
+ }
1083
+ did_finish = true;
1084
+ clearTimeout(stop_timeout_handle);
1085
+ done(error || null);
1086
+ };
1087
+
1088
+ if (!target || typeof target.stop !== 'function') {
1089
+ finish_stop(null);
1090
+ return;
1091
+ }
1092
+
1093
+ if (target.stop.length >= 1) {
1094
+ try {
1095
+ target.stop((error) => finish_stop(error || null));
1096
+ } catch (error) {
1097
+ finish_stop(error);
1098
+ }
1099
+ return;
1100
+ }
1101
+
1102
+ try {
1103
+ const stop_result = target.stop();
1104
+ if (stop_result && typeof stop_result.then === 'function') {
1105
+ stop_result.then(() => finish_stop(null), (error) => finish_stop(error || null));
1106
+ return;
1107
+ }
1108
+ finish_stop(null);
1109
+ } catch (error) {
1110
+ finish_stop(error);
1111
+ }
1112
+ };
1113
+
1114
+ const force_close_server_connections = (server_instance) => {
1115
+ if (!server_instance) {
1116
+ return;
1117
+ }
1118
+ if (typeof server_instance.closeIdleConnections === 'function') {
1119
+ try {
1120
+ server_instance.closeIdleConnections();
1121
+ } catch (error) {
1122
+ // Ignore best-effort close idle connection errors.
1123
+ }
1124
+ }
1125
+ if (typeof server_instance.closeAllConnections === 'function') {
1126
+ try {
1127
+ server_instance.closeAllConnections();
1128
+ } catch (error) {
1129
+ // Ignore best-effort close all connection errors.
1130
+ }
1131
+ }
1132
+ if (server_instance._open_sockets && typeof server_instance._open_sockets.forEach === 'function') {
1133
+ server_instance._open_sockets.forEach((socket) => {
1134
+ try {
1135
+ socket.destroy();
1136
+ } catch (error) {
1137
+ // Ignore socket destroy errors during shutdown.
1138
+ }
1139
+ });
1140
+ server_instance._open_sockets.clear?.();
1141
+ }
1142
+ };
1143
+
1144
+ const close_http_servers = (done) => {
1145
+ const http_server_list = Array.isArray(this.http_servers) ? [...this.http_servers] : [];
1146
+ let pending_server_count = http_server_list.length;
1147
+ const complete_http_server_close = () => {
1148
+ this.http_servers = [];
1149
+ this.listening_endpoints = [];
1150
+ this.startup_diagnostics = null;
1151
+ this._started = false;
1152
+ done();
1153
+ };
1154
+ if (pending_server_count === 0) {
1155
+ complete_http_server_close();
1156
+ return;
1157
+ }
1158
+
1159
+ http_server_list.forEach((server_instance) => {
1160
+ let did_close_server = false;
1161
+ const mark_server_closed = () => {
1162
+ if (did_close_server) {
1163
+ return;
1164
+ }
1165
+ did_close_server = true;
1166
+ pending_server_count--;
1167
+ if (pending_server_count === 0) {
1168
+ complete_http_server_close();
1169
+ }
1170
+ };
1171
+
1172
+ const close_timeout_ms = 5000;
1173
+ const close_timeout_handle = setTimeout(() => {
1174
+ force_close_server_connections(server_instance);
1175
+ mark_server_closed();
1176
+ }, close_timeout_ms);
1177
+ close_timeout_handle.unref?.();
1178
+
1179
+ try {
1180
+ force_close_server_connections(server_instance);
1181
+ server_instance.close((error) => {
1182
+ clearTimeout(close_timeout_handle);
1183
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') {
1184
+ console.error('Error while closing HTTP server:', error);
1185
+ }
1186
+ force_close_server_connections(server_instance);
1187
+ mark_server_closed();
1188
+ });
1189
+ } catch (error) {
1190
+ clearTimeout(close_timeout_handle);
1191
+ force_close_server_connections(server_instance);
1192
+ mark_server_closed();
1193
+ }
1194
+ });
1195
+ };
778
1196
 
779
1197
  const finalize_close = (error) => {
780
1198
  if (callback) callback(error || null);