jsgui3-server 0.0.151 → 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 (77) 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/api-reference.md +120 -2
  5. package/docs/books/website-design/01-introduction.md +73 -0
  6. package/docs/books/website-design/02-current-state.md +195 -0
  7. package/docs/books/website-design/03-base-class.md +181 -0
  8. package/docs/books/website-design/04-webpage.md +307 -0
  9. package/docs/books/website-design/05-website.md +456 -0
  10. package/docs/books/website-design/06-pages-storage.md +170 -0
  11. package/docs/books/website-design/07-api-layer.md +285 -0
  12. package/docs/books/website-design/08-server-integration.md +271 -0
  13. package/docs/books/website-design/09-cross-agent-review.md +190 -0
  14. package/docs/books/website-design/10-open-questions.md +196 -0
  15. package/docs/books/website-design/11-converged-recommendation.md +205 -0
  16. package/docs/books/website-design/12-content-model.md +395 -0
  17. package/docs/books/website-design/13-webpage-module-spec.md +404 -0
  18. package/docs/books/website-design/14-website-module-spec.md +541 -0
  19. package/docs/books/website-design/15-multi-repo-plan.md +275 -0
  20. package/docs/books/website-design/16-minimal-first.md +203 -0
  21. package/docs/books/website-design/17-implementation-report-codex.md +81 -0
  22. package/docs/books/website-design/README.md +43 -0
  23. package/docs/configuration-reference.md +54 -0
  24. package/docs/proposals/jsgui3-website-and-webpage-design-jsgui3-server-support.md +257 -0
  25. package/docs/proposals/jsgui3-website-and-webpage-design-review.md +73 -0
  26. package/docs/proposals/jsgui3-website-and-webpage-design.md +732 -0
  27. package/docs/swagger.md +316 -0
  28. package/examples/controls/1) window/server.js +6 -1
  29. package/examples/controls/21) mvvm and declarative api/check.js +94 -0
  30. package/examples/controls/21) mvvm and declarative api/check_output.txt +25 -0
  31. package/examples/controls/21) mvvm and declarative api/check_output_2.txt +27 -0
  32. package/examples/controls/21) mvvm and declarative api/client.js +241 -0
  33. declarative api/e2e-screenshot-1-name-change.png +0 -0
  34. declarative api/e2e-screenshot-2-toggled.png +0 -0
  35. declarative api/e2e-screenshot-3-final.png +0 -0
  36. declarative api/e2e-screenshot-final.png +0 -0
  37. package/examples/controls/21) mvvm and declarative api/e2e-test.js +175 -0
  38. package/examples/controls/21) mvvm and declarative api/out.html +1 -0
  39. package/examples/controls/21) mvvm and declarative api/page_out.html +1 -0
  40. package/examples/controls/21) mvvm and declarative api/server.js +18 -0
  41. package/examples/data-views/01) query-endpoint/server.js +61 -0
  42. package/labs/website-design/001-base-class-overhead/check.js +162 -0
  43. package/labs/website-design/002-pages-storage/check.js +244 -0
  44. package/labs/website-design/002-pages-storage/results.txt +0 -0
  45. package/labs/website-design/003-type-detection/check.js +193 -0
  46. package/labs/website-design/003-type-detection/results.txt +0 -0
  47. package/labs/website-design/004-two-stage-validation/check.js +314 -0
  48. package/labs/website-design/004-two-stage-validation/results.txt +0 -0
  49. package/labs/website-design/005-normalize-input/check.js +303 -0
  50. package/labs/website-design/006-serve-website-spike/check.js +290 -0
  51. package/labs/website-design/README.md +34 -0
  52. package/labs/website-design/manifest.json +68 -0
  53. package/labs/website-design/run-all.js +60 -0
  54. package/middleware/json-body.js +126 -0
  55. package/openapi.js +474 -0
  56. package/package.json +11 -8
  57. package/publishers/Publishers.js +6 -5
  58. package/publishers/http-function-publisher.js +135 -126
  59. package/publishers/http-webpage-publisher.js +89 -11
  60. package/publishers/query-publisher.js +116 -0
  61. package/publishers/swagger-publisher.js +203 -0
  62. package/publishers/swagger-ui.js +578 -0
  63. package/resources/adapters/array-adapter.js +143 -0
  64. package/resources/query-resource.js +131 -0
  65. package/serve-factory.js +728 -18
  66. package/server.js +421 -103
  67. package/tests/README.md +23 -1
  68. package/tests/admin-ui-jsgui-controls.test.js +16 -1
  69. package/tests/helpers/playwright-e2e-harness.js +326 -0
  70. package/tests/openapi.test.js +319 -0
  71. package/tests/playwright-smoke.test.js +134 -0
  72. package/tests/publish-enhancements.test.js +673 -0
  73. package/tests/query-publisher.test.js +430 -0
  74. package/tests/quick-json-body-test.js +169 -0
  75. package/tests/serve.test.js +425 -122
  76. package/tests/swagger-publisher.test.js +1076 -0
  77. package/tests/test-runner.js +1 -0
package/serve-factory.js CHANGED
@@ -6,6 +6,7 @@ const {
6
6
  ensure_route_leading_slash
7
7
  } = require('./serve-helpers');
8
8
  const lib_path = require('path');
9
+ const Website = require('./website/website');
9
10
  const Webpage = require('./website/webpage');
10
11
  const HTTP_Webpage_Publisher = require('./publishers/http-webpage-publisher');
11
12
  const HTTP_SSE_Publisher = require('./publishers/http-sse-publisher');
@@ -14,6 +15,35 @@ const Process_Resource = require('./resources/process-resource');
14
15
  const Remote_Process_Resource = require('./resources/remote-process-resource');
15
16
  const { get_port_or_free } = require('./port-utils');
16
17
 
18
+ const website_marker = Symbol.for('jsgui3.website');
19
+ const webpage_marker = Symbol.for('jsgui3.webpage');
20
+
21
+ const strip_trailing_slash = (route_value) => {
22
+ if (!route_value) {
23
+ return '/';
24
+ }
25
+
26
+ let normalized_route = ensure_route_leading_slash(String(route_value));
27
+ while (normalized_route.length > 1 && normalized_route.endsWith('/')) {
28
+ normalized_route = normalized_route.slice(0, -1);
29
+ }
30
+ return normalized_route;
31
+ };
32
+
33
+ const normalize_route_path = (route_value, fallback_route = '/') => {
34
+ const route_candidate = route_value || fallback_route || '/';
35
+ return strip_trailing_slash(route_candidate);
36
+ };
37
+
38
+ const normalize_base_path = (base_path) => {
39
+ if (!base_path) {
40
+ return '';
41
+ }
42
+
43
+ const normalized_base_path = normalize_route_path(base_path, '/');
44
+ return normalized_base_path === '/' ? '' : normalized_base_path;
45
+ };
46
+
17
47
  const prepare_webpage_route = (server, route, page_options = {}, defaults = {}) => {
18
48
  return new Promise((resolve, reject) => {
19
49
  try {
@@ -49,9 +79,14 @@ const prepare_webpage_route = (server, route, page_options = {}, defaults = {})
49
79
  webpage_publisher.on('ready', (bundle) => {
50
80
  try {
51
81
  if (bundle && bundle._arr) {
82
+ const target_router = server.router || server.server_router;
83
+ if (!target_router || typeof target_router.set_route !== 'function') {
84
+ reject(new Error(`Server router is unavailable while preparing route ${route}`));
85
+ return;
86
+ }
52
87
  for (const item of bundle._arr) {
53
88
  const static_responder = new Static_Route_HTTP_Responder(item);
54
- server.router.set_route(item.route, static_responder, static_responder.handle_http);
89
+ target_router.set_route(item.route, static_responder, static_responder.handle_http);
55
90
  }
56
91
  resolve();
57
92
  return;
@@ -197,8 +232,404 @@ const normalize_resource_entries = (resources_option) => {
197
232
  throw new Error('`resources` must be an object map or an array.');
198
233
  };
199
234
 
235
+ const is_plain_object = (value) => {
236
+ return !!value && typeof value === 'object' && !Array.isArray(value);
237
+ };
238
+
239
+ const is_website_like = (value) => {
240
+ if (!value || typeof value !== 'object') {
241
+ return false;
242
+ }
243
+
244
+ if (value[website_marker] === true) {
245
+ return true;
246
+ }
247
+
248
+ if (value instanceof Website) {
249
+ return true;
250
+ }
251
+
252
+ const has_pages_collection = value.pages !== undefined || value._pages !== undefined;
253
+ if (has_pages_collection) {
254
+ return true;
255
+ }
256
+
257
+ return typeof value.add_page === 'function'
258
+ && typeof value.get_page === 'function';
259
+ };
260
+
261
+ const is_webpage_like = (value) => {
262
+ if (!value || typeof value !== 'object') {
263
+ return false;
264
+ }
265
+
266
+ if (value[webpage_marker] === true) {
267
+ return true;
268
+ }
269
+
270
+ if (value instanceof Webpage) {
271
+ return true;
272
+ }
273
+
274
+ return typeof value.path === 'string'
275
+ && (
276
+ typeof value.ctrl === 'function'
277
+ || typeof value.Ctrl === 'function'
278
+ || typeof value.content === 'function'
279
+ );
280
+ };
281
+
282
+ const extract_page_ctrl = (page_spec = {}) => {
283
+ const candidate_ctrl = page_spec.ctrl || page_spec.Ctrl;
284
+ if (candidate_ctrl !== undefined) {
285
+ return candidate_ctrl;
286
+ }
287
+
288
+ if (typeof page_spec.content === 'function') {
289
+ return page_spec.content;
290
+ }
291
+
292
+ return undefined;
293
+ };
294
+
295
+ const normalize_page_entry = (page_spec = {}, fallback_route = '/') => {
296
+ const source_spec = typeof page_spec === 'function'
297
+ ? { ctrl: page_spec }
298
+ : (is_plain_object(page_spec) ? { ...page_spec } : {});
299
+
300
+ const route_value = source_spec.path || source_spec.route || fallback_route || '/';
301
+ const route = normalize_route_path(route_value, '/');
302
+
303
+ const content_ctrl = extract_page_ctrl(source_spec);
304
+ const normalized_page = {
305
+ ...source_spec,
306
+ path: route,
307
+ route,
308
+ ctrl: content_ctrl,
309
+ Ctrl: content_ctrl,
310
+ content: content_ctrl
311
+ };
312
+
313
+ if (source_spec.content !== undefined && typeof source_spec.content !== 'function') {
314
+ normalized_page.content_data = source_spec.content;
315
+ }
316
+
317
+ return [route, normalized_page];
318
+ };
319
+
320
+ const normalize_website_pages = (website_value) => {
321
+ const normalized_pages = [];
322
+ const push_page_entry = (page_entry, fallback_route) => {
323
+ normalized_pages.push(normalize_page_entry(page_entry, fallback_route));
324
+ };
325
+
326
+ if (Array.isArray(website_value.pages)) {
327
+ for (const page_entry of website_value.pages) {
328
+ push_page_entry(page_entry, page_entry && page_entry.path ? page_entry.path : '/');
329
+ }
330
+ return normalized_pages;
331
+ }
332
+
333
+ if (website_value.pages instanceof Map) {
334
+ for (const [route_key, page_entry] of website_value.pages.entries()) {
335
+ push_page_entry(page_entry, route_key);
336
+ }
337
+ return normalized_pages;
338
+ }
339
+
340
+ if (website_value.pages && Array.isArray(website_value.pages._arr)) {
341
+ for (const page_entry of website_value.pages._arr) {
342
+ push_page_entry(page_entry, page_entry && page_entry.path ? page_entry.path : '/');
343
+ }
344
+ return normalized_pages;
345
+ }
346
+
347
+ if (website_value._pages instanceof Map) {
348
+ for (const [route_key, page_entry] of website_value._pages.entries()) {
349
+ push_page_entry(page_entry, route_key);
350
+ }
351
+ return normalized_pages;
352
+ }
353
+
354
+ if (is_plain_object(website_value.pages)) {
355
+ for (const [route_key, page_entry] of Object.entries(website_value.pages)) {
356
+ push_page_entry(page_entry, route_key);
357
+ }
358
+ }
359
+
360
+ return normalized_pages;
361
+ };
362
+
363
+ const normalize_endpoint_entry = (endpoint_name, endpoint_value = {}, default_base_path = '') => {
364
+ const normalized_base_path = normalize_base_path(default_base_path);
365
+ const resolve_default_endpoint_path = (route_name) => {
366
+ if (typeof route_name === 'string' && route_name.startsWith('/')) {
367
+ return join_base_path(normalized_base_path, route_name);
368
+ }
369
+ if (typeof route_name === 'string' && route_name.length > 0) {
370
+ return join_base_path(normalized_base_path, `/api/${route_name}`);
371
+ }
372
+ return join_base_path(normalized_base_path, '/api');
373
+ };
374
+
375
+ if (typeof endpoint_value === 'function') {
376
+ if (typeof endpoint_name !== 'string' || endpoint_name.length === 0) {
377
+ return null;
378
+ }
379
+ return {
380
+ name: endpoint_name,
381
+ handler: endpoint_value,
382
+ method: 'GET',
383
+ path: resolve_default_endpoint_path(endpoint_name)
384
+ };
385
+ }
386
+
387
+ if (!is_plain_object(endpoint_value) || typeof endpoint_value.handler !== 'function') {
388
+ return null;
389
+ }
390
+
391
+ const endpoint_label = endpoint_value.name || endpoint_name;
392
+ if (!endpoint_value.path && (typeof endpoint_label !== 'string' || endpoint_label.length === 0)) {
393
+ return null;
394
+ }
395
+
396
+ const endpoint_method = endpoint_value.method || 'GET';
397
+ const endpoint_path = endpoint_value.path
398
+ ? normalize_route_path(endpoint_value.path, '/')
399
+ : resolve_default_endpoint_path(endpoint_label);
400
+
401
+ return {
402
+ name: endpoint_label,
403
+ handler: endpoint_value.handler,
404
+ method: endpoint_method,
405
+ path: endpoint_path,
406
+ description: endpoint_value.description,
407
+ // Extended API metadata for OpenAPI / Swagger generation.
408
+ summary: endpoint_value.summary,
409
+ tags: endpoint_value.tags,
410
+ params: endpoint_value.params,
411
+ returns: endpoint_value.returns,
412
+ schema: endpoint_value.schema,
413
+ // Enhancement support: raw handler, deprecated, operationId.
414
+ raw: endpoint_value.raw,
415
+ deprecated: endpoint_value.deprecated,
416
+ operationId: endpoint_value.operationId
417
+ };
418
+ };
419
+
420
+ const normalize_website_endpoints = (website_value, normalized_base_path = '') => {
421
+ const normalized_endpoints = [];
422
+ const push_endpoint = (endpoint_entry, endpoint_name) => {
423
+ if (!endpoint_entry) {
424
+ return;
425
+ }
426
+
427
+ if (typeof endpoint_entry === 'function') {
428
+ if (typeof endpoint_name !== 'string' || endpoint_name.length === 0) {
429
+ return;
430
+ }
431
+ const normalized_endpoint = normalize_endpoint_entry(endpoint_name, endpoint_entry, normalized_base_path);
432
+ if (normalized_endpoint) {
433
+ normalized_endpoints.push(normalized_endpoint);
434
+ }
435
+ return;
436
+ }
437
+
438
+ if (is_plain_object(endpoint_entry) && typeof endpoint_entry.handler === 'function') {
439
+ const normalized_endpoint = normalize_endpoint_entry(
440
+ endpoint_name || endpoint_entry.name,
441
+ endpoint_entry,
442
+ normalized_base_path
443
+ );
444
+ if (normalized_endpoint) {
445
+ normalized_endpoints.push(normalized_endpoint);
446
+ }
447
+ return;
448
+ }
449
+
450
+ if (is_plain_object(endpoint_entry) && typeof endpoint_entry.publish === 'function') {
451
+ const normalized_endpoint = normalize_endpoint_entry(endpoint_name, endpoint_entry.publish, normalized_base_path);
452
+ if (!normalized_endpoint) {
453
+ return;
454
+ }
455
+ normalized_endpoints.push(normalized_endpoint);
456
+ }
457
+ };
458
+
459
+ if (Array.isArray(website_value.api_endpoints)) {
460
+ for (const endpoint_entry of website_value.api_endpoints) {
461
+ push_endpoint(endpoint_entry, endpoint_entry && endpoint_entry.name);
462
+ }
463
+ return normalized_endpoints;
464
+ }
465
+
466
+ if (website_value._api instanceof Map) {
467
+ for (const [endpoint_name, endpoint_entry] of website_value._api.entries()) {
468
+ push_endpoint(
469
+ is_plain_object(endpoint_entry)
470
+ ? { name: endpoint_name, ...endpoint_entry }
471
+ : endpoint_entry,
472
+ endpoint_name
473
+ );
474
+ }
475
+ return normalized_endpoints;
476
+ }
477
+
478
+ if (website_value.api && typeof website_value.api[Symbol.iterator] === 'function' && !is_plain_object(website_value.api)) {
479
+ for (const endpoint_entry of website_value.api) {
480
+ if (Array.isArray(endpoint_entry)) {
481
+ const [endpoint_name, endpoint_value] = endpoint_entry;
482
+ const normalized_endpoint = normalize_endpoint_entry(endpoint_name, endpoint_value, normalized_base_path);
483
+ if (normalized_endpoint) {
484
+ normalized_endpoints.push(normalized_endpoint);
485
+ }
486
+ } else {
487
+ push_endpoint(endpoint_entry, endpoint_entry && endpoint_entry.name);
488
+ }
489
+ }
490
+ return normalized_endpoints;
491
+ }
492
+
493
+ if (is_plain_object(website_value.api)) {
494
+ for (const [endpoint_name, endpoint_value] of Object.entries(website_value.api)) {
495
+ const normalized_endpoint = normalize_endpoint_entry(endpoint_name, endpoint_value, normalized_base_path);
496
+ if (normalized_endpoint) {
497
+ normalized_endpoints.push(normalized_endpoint);
498
+ }
499
+ }
500
+ }
501
+
502
+ return normalized_endpoints;
503
+ };
504
+
505
+ const join_base_path = (base_path, route_path) => {
506
+ const normalized_route = normalize_route_path(route_path || '/', '/');
507
+ const normalized_base = normalize_base_path(base_path);
508
+
509
+ if (!normalized_base) {
510
+ return normalized_route;
511
+ }
512
+
513
+ if (normalized_route === '/') {
514
+ return normalized_base;
515
+ }
516
+
517
+ return `${normalized_base}${normalized_route}`;
518
+ };
519
+
520
+ const dedupe_normalized_endpoints = (normalized_endpoints = []) => {
521
+ const deduped_endpoints = [];
522
+ const seen_endpoint_keys = new Set();
523
+
524
+ for (const endpoint of normalized_endpoints) {
525
+ if (!endpoint || typeof endpoint.handler !== 'function') {
526
+ continue;
527
+ }
528
+
529
+ const endpoint_method = String(endpoint.method || 'GET').toUpperCase();
530
+ const endpoint_path = endpoint.path
531
+ ? normalize_route_path(endpoint.path, '/')
532
+ : (
533
+ typeof endpoint.name === 'string' && endpoint.name.length > 0
534
+ ? endpoint.name
535
+ : ''
536
+ );
537
+ const endpoint_key = `${endpoint_method} ${endpoint_path}`;
538
+ if (seen_endpoint_keys.has(endpoint_key)) {
539
+ continue;
540
+ }
541
+
542
+ seen_endpoint_keys.add(endpoint_key);
543
+ deduped_endpoints.push({
544
+ ...endpoint,
545
+ method: endpoint_method,
546
+ path: endpoint.path ? normalize_route_path(endpoint.path, '/') : endpoint.path
547
+ });
548
+ }
549
+
550
+ return deduped_endpoints;
551
+ };
552
+
553
+ const normalize_api_endpoints_from_options = (serve_options = {}, base_path = '') => {
554
+ const collected_endpoints = [];
555
+
556
+ if (Array.isArray(serve_options.api_endpoints)) {
557
+ collected_endpoints.push(...normalize_website_endpoints({
558
+ api_endpoints: serve_options.api_endpoints
559
+ }, base_path));
560
+ } else if (is_plain_object(serve_options.api_endpoints)) {
561
+ collected_endpoints.push(...normalize_website_endpoints({
562
+ api: serve_options.api_endpoints
563
+ }, base_path));
564
+ }
565
+
566
+ if (serve_options.api && typeof serve_options.api === 'object') {
567
+ collected_endpoints.push(...normalize_website_endpoints({
568
+ api: serve_options.api
569
+ }, base_path));
570
+ }
571
+
572
+ return dedupe_normalized_endpoints(collected_endpoints);
573
+ };
574
+
575
+ const get_manifest_candidate = (input_value, serve_options = {}) => {
576
+ const input_manifest = normalize_serve_input(input_value);
577
+ if (input_manifest) {
578
+ return input_manifest;
579
+ }
580
+
581
+ if (serve_options && typeof serve_options === 'object') {
582
+ const explicit_webpage = normalize_serve_input(serve_options.webpage);
583
+ if (explicit_webpage) {
584
+ return explicit_webpage;
585
+ }
586
+
587
+ const explicit_website = normalize_serve_input(serve_options.website);
588
+ if (explicit_website) {
589
+ return explicit_website;
590
+ }
591
+ }
592
+
593
+ return null;
594
+ };
595
+
596
+ const normalize_serve_input = (input_value) => {
597
+ if (is_webpage_like(input_value)) {
598
+ const [route, page_config] = normalize_page_entry(input_value, input_value.path || '/');
599
+ return {
600
+ source: 'webpage',
601
+ name: input_value.name,
602
+ meta: input_value.meta || {},
603
+ assets: input_value.assets || {},
604
+ pages: [[route, page_config]],
605
+ api_endpoints: []
606
+ };
607
+ }
608
+
609
+ if (is_website_like(input_value)) {
610
+ const base_path = normalize_base_path(input_value.base_path);
611
+ const normalized_pages = normalize_website_pages(input_value).map(([route, page_config]) => {
612
+ const route_with_base = join_base_path(base_path, route);
613
+ return [route_with_base, { ...page_config, path: route_with_base, route: route_with_base }];
614
+ });
615
+
616
+ const normalized_endpoints = normalize_website_endpoints(input_value, base_path);
617
+ return {
618
+ source: 'website',
619
+ name: input_value.name,
620
+ meta: input_value.meta || {},
621
+ assets: input_value.assets || {},
622
+ base_path: base_path || undefined,
623
+ pages: normalized_pages,
624
+ api_endpoints: normalized_endpoints
625
+ };
626
+ }
627
+
628
+ return null;
629
+ };
630
+
200
631
  module.exports = (Server) => {
201
- const serve = function(input, maybe_options, maybe_callback) {
632
+ const serve = function (input, maybe_options, maybe_callback) {
202
633
  let callback = null;
203
634
  if (typeof maybe_options === 'function') {
204
635
  callback = maybe_options;
@@ -225,6 +656,65 @@ module.exports = (Server) => {
225
656
  };
226
657
  }
227
658
 
659
+ const has_explicit_page_overrides = !!(
660
+ maybe_options
661
+ && typeof maybe_options === 'object'
662
+ && (
663
+ maybe_options.page !== undefined
664
+ || maybe_options.pages !== undefined
665
+ || maybe_options.ctrl !== undefined
666
+ || maybe_options.Ctrl !== undefined
667
+ )
668
+ );
669
+
670
+ const normalized_input_manifest = get_manifest_candidate(input, serve_options);
671
+ if (normalized_input_manifest) {
672
+ if (!serve_options.name && normalized_input_manifest.name) {
673
+ serve_options.name = normalized_input_manifest.name;
674
+ }
675
+
676
+ if (
677
+ !has_explicit_page_overrides
678
+ && !serve_options.page
679
+ && Array.isArray(normalized_input_manifest.pages)
680
+ && normalized_input_manifest.pages.length
681
+ ) {
682
+ const normalized_pages_map = {};
683
+ const seen_routes = new Set();
684
+ for (const [route, page_config] of normalized_input_manifest.pages) {
685
+ const normalized_route = normalize_route_path(route, '/');
686
+ if (seen_routes.has(normalized_route)) {
687
+ throw new Error(`duplicate_route: ${normalized_route}`);
688
+ }
689
+ seen_routes.add(normalized_route);
690
+ normalized_pages_map[normalized_route] = page_config || {};
691
+ }
692
+ serve_options.pages = normalized_pages_map;
693
+
694
+ if (
695
+ normalized_input_manifest.source === 'webpage'
696
+ || normalized_input_manifest.source === 'website'
697
+ ) {
698
+ delete serve_options.ctrl;
699
+ delete serve_options.Ctrl;
700
+ }
701
+ }
702
+
703
+ if (
704
+ !serve_options.api
705
+ && !serve_options.api_endpoints
706
+ && Array.isArray(normalized_input_manifest.api_endpoints)
707
+ && normalized_input_manifest.api_endpoints.length
708
+ ) {
709
+ serve_options.api_endpoints = normalized_input_manifest.api_endpoints;
710
+ }
711
+ }
712
+
713
+ const manifest_base_path = normalize_base_path(
714
+ (normalized_input_manifest && normalized_input_manifest.base_path)
715
+ || serve_options.base_path
716
+ );
717
+
228
718
  const caller_file = serve_options.caller_file || serve_options.callerFile || guess_caller_file();
229
719
  const caller_dir = serve_options.root
230
720
  ? lib_path.resolve(process.cwd(), serve_options.root)
@@ -234,29 +724,47 @@ module.exports = (Server) => {
234
724
  if (!serve_options.ctrl && serve_options.page) {
235
725
  const page_config = serve_options.page;
236
726
  serve_options.ctrl = page_config.content || page_config.ctrl || page_config.Ctrl;
237
- serve_options.page_route = ensure_route_leading_slash(page_config.route || '/');
727
+ serve_options.page_route = normalize_route_path(page_config.route || page_config.path || '/', '/');
238
728
  serve_options.page_config = page_config;
239
729
  }
240
730
 
241
731
  let additional_pages = [];
732
+ let use_manual_page_publication = false;
733
+ if (!serve_options.ctrl && Array.isArray(serve_options.pages)) {
734
+ throw new Error('`pages` option must be an object map of route -> page config.');
735
+ }
736
+
242
737
  if (!serve_options.ctrl && serve_options.pages && typeof serve_options.pages === 'object') {
243
738
  const page_entries = Object.entries(serve_options.pages);
244
739
  if (!page_entries.length) {
245
740
  throw new Error('`pages` option requires at least one entry.');
246
741
  }
247
742
 
248
- const normalized_pages = page_entries.map(([route, cfg]) => [ensure_route_leading_slash(route), cfg || {}]);
249
- const root_entry = normalized_pages.find(([route]) => route === '/') || normalized_pages[0];
250
- serve_options.ctrl = (root_entry[1].content || root_entry[1].ctrl || root_entry[1].Ctrl);
251
- serve_options.page_route = root_entry[0];
252
- serve_options.page_config = root_entry[1];
253
- additional_pages = normalized_pages.filter(([route]) => route !== serve_options.page_route);
743
+ const normalized_pages = page_entries.map(([route, cfg]) => [normalize_route_path(route, '/'), cfg || {}]);
744
+ const seen_page_routes = new Set();
745
+ for (const [route] of normalized_pages) {
746
+ if (seen_page_routes.has(route)) {
747
+ throw new Error(`duplicate_route: ${route}`);
748
+ }
749
+ seen_page_routes.add(route);
750
+ }
751
+
752
+ const root_entry = normalized_pages.find(([route]) => route === '/');
753
+ if (root_entry) {
754
+ serve_options.ctrl = (root_entry[1].content || root_entry[1].ctrl || root_entry[1].Ctrl);
755
+ serve_options.page_route = root_entry[0];
756
+ serve_options.page_config = root_entry[1];
757
+ additional_pages = normalized_pages.filter(([route]) => route !== serve_options.page_route);
758
+ } else {
759
+ use_manual_page_publication = true;
760
+ additional_pages = normalized_pages;
761
+ }
254
762
  }
255
763
 
256
764
  const explicit_client_path = serve_options.clientPath || serve_options.client_path || serve_options.src_path_client_js || serve_options.disk_path_client_js;
257
765
  const root_client_path = find_default_client_path(explicit_client_path, caller_dir);
258
766
 
259
- if (typeof serve_options.ctrl !== 'function' && root_client_path) {
767
+ if (typeof serve_options.ctrl !== 'function' && root_client_path && !use_manual_page_publication) {
260
768
  const auto_ctrl = load_default_control_from_client(root_client_path);
261
769
  if (typeof auto_ctrl === 'function') {
262
770
  serve_options.ctrl = auto_ctrl;
@@ -266,10 +774,22 @@ module.exports = (Server) => {
266
774
  if (serve_options.page_config && typeof serve_options.ctrl !== 'function') {
267
775
  throw new Error('`page` option requires a control constructor.');
268
776
  }
269
- if (additional_pages.length && typeof serve_options.ctrl !== 'function') {
777
+ if (additional_pages.length && !use_manual_page_publication && typeof serve_options.ctrl !== 'function') {
270
778
  throw new Error('`pages` option requires at least one control constructor.');
271
779
  }
272
780
 
781
+ if (use_manual_page_publication) {
782
+ const invalid_page_entry = additional_pages.find(([, cfg]) => {
783
+ const page_ctrl = cfg && (cfg.content || cfg.ctrl || cfg.Ctrl);
784
+ return typeof page_ctrl !== 'function';
785
+ });
786
+ if (invalid_page_entry) {
787
+ throw new Error(`Page at route "${invalid_page_entry[0]}" requires a control constructor as content.`);
788
+ }
789
+ }
790
+
791
+ const normalized_api_endpoints = normalize_api_endpoints_from_options(serve_options, manifest_base_path);
792
+
273
793
  const port = Number.isFinite(serve_options.port)
274
794
  ? Number(serve_options.port)
275
795
  : (serve_options.port === 'auto' ? 0 : (process.env.PORT ? Number(process.env.PORT) : 8080));
@@ -293,7 +813,11 @@ module.exports = (Server) => {
293
813
 
294
814
  if (typeof serve_options.ctrl === 'function') {
295
815
  server_spec.Ctrl = serve_options.ctrl;
296
- } else if (serve_options.api && typeof serve_options.api === 'object') {
816
+ } else if (
817
+ (serve_options.api && typeof serve_options.api === 'object')
818
+ || normalized_api_endpoints.length
819
+ || use_manual_page_publication
820
+ ) {
297
821
  server_spec.website = false;
298
822
  }
299
823
 
@@ -383,6 +907,85 @@ module.exports = (Server) => {
383
907
  ? Number(serve_options.readyTimeoutMs)
384
908
  : 120000;
385
909
 
910
+ const manifest_page_entries = [];
911
+ const seen_manifest_routes = new Set();
912
+ const push_manifest_page = (route, page_config = {}) => {
913
+ const normalized_route = normalize_route_path(route, '/');
914
+ if (seen_manifest_routes.has(normalized_route)) {
915
+ return;
916
+ }
917
+ seen_manifest_routes.add(normalized_route);
918
+ manifest_page_entries.push([normalized_route, page_config || {}]);
919
+ };
920
+
921
+ if (normalized_input_manifest && Array.isArray(normalized_input_manifest.pages) && normalized_input_manifest.pages.length) {
922
+ for (const [route, page_config] of normalized_input_manifest.pages) {
923
+ push_manifest_page(route, page_config);
924
+ }
925
+ } else {
926
+ if (serve_options.page_config && serve_options.page_route) {
927
+ push_manifest_page(serve_options.page_route, serve_options.page_config);
928
+ }
929
+ for (const [route, page_config] of additional_pages) {
930
+ push_manifest_page(route, page_config);
931
+ }
932
+ if (!manifest_page_entries.length && typeof serve_options.ctrl === 'function' && !use_manual_page_publication) {
933
+ push_manifest_page('/', {
934
+ ctrl: serve_options.ctrl,
935
+ content: serve_options.ctrl,
936
+ name: serve_options.name
937
+ });
938
+ }
939
+ }
940
+
941
+ const manifest_warning_messages = [];
942
+ const non_get_endpoints = normalized_api_endpoints.filter((endpoint) => endpoint.method !== 'GET');
943
+ if (non_get_endpoints.length) {
944
+ manifest_warning_messages.push(
945
+ 'API endpoint metadata includes non-GET methods, but server.publish currently treats handlers as method-agnostic.'
946
+ );
947
+ }
948
+
949
+ const effective_website_manifest = {
950
+ source: (normalized_input_manifest && normalized_input_manifest.source)
951
+ || (use_manual_page_publication ? 'pages' : (typeof serve_options.ctrl === 'function' ? 'ctrl' : 'legacy')),
952
+ name: serve_options.name || server_spec.name || 'jsgui3 server',
953
+ base_path: manifest_base_path || undefined,
954
+ meta: (normalized_input_manifest && normalized_input_manifest.meta) || serve_options.meta || {},
955
+ assets: (normalized_input_manifest && normalized_input_manifest.assets) || serve_options.assets || {},
956
+ pages: manifest_page_entries.map(([route, page_config]) => {
957
+ const page_ctrl = extract_page_ctrl(page_config || {});
958
+ return {
959
+ route,
960
+ path: route,
961
+ name: page_config ? page_config.name : undefined,
962
+ title: page_config ? page_config.title : undefined,
963
+ render_mode: (page_config && page_config.render_mode)
964
+ || (typeof page_ctrl === 'function' ? 'dynamic' : 'static'),
965
+ has_ctrl: typeof page_ctrl === 'function'
966
+ };
967
+ }),
968
+ api_endpoints: normalized_api_endpoints.map((endpoint) => ({
969
+ name: endpoint.name,
970
+ method: endpoint.method || 'GET',
971
+ path: endpoint.path,
972
+ description: endpoint.description,
973
+ summary: endpoint.summary,
974
+ tags: endpoint.tags,
975
+ params: endpoint.params,
976
+ returns: endpoint.returns,
977
+ schema: endpoint.schema
978
+ }))
979
+ };
980
+
981
+ server_instance.website_manifest = effective_website_manifest;
982
+ server_instance.publication_summary = {
983
+ source: effective_website_manifest.source,
984
+ page_routes: effective_website_manifest.pages.map((page) => page.path),
985
+ api_routes: effective_website_manifest.api_endpoints.map((endpoint) => `${endpoint.method} ${endpoint.path}`),
986
+ warnings: manifest_warning_messages
987
+ };
988
+
386
989
  const extra_page_promises = additional_pages.map(([route, cfg]) => prepare_webpage_route(server_instance, route, cfg, {
387
990
  caller_dir,
388
991
  debug: debug_enabled,
@@ -399,14 +1002,114 @@ module.exports = (Server) => {
399
1002
  }));
400
1003
  }
401
1004
 
402
- if (serve_options.api && typeof serve_options.api === 'object') {
403
- for (const [name, handler] of Object.entries(serve_options.api)) {
404
- if (typeof handler === 'function') {
405
- server_instance.publish(name, handler);
1005
+ // ── Register middleware ──────────────────────────────
1006
+ // `middleware` accepts an array of (req, res, next) functions.
1007
+ if (Array.isArray(serve_options.middleware)) {
1008
+ for (const mw of serve_options.middleware) {
1009
+ if (typeof mw === 'function') {
1010
+ server_instance.use(mw);
406
1011
  }
407
1012
  }
408
1013
  }
409
1014
 
1015
+ for (const endpoint of normalized_api_endpoints) {
1016
+ if (!endpoint || typeof endpoint.handler !== 'function') {
1017
+ continue;
1018
+ }
1019
+
1020
+ const endpoint_route = endpoint.path || endpoint.name;
1021
+ if (typeof endpoint_route !== 'string' || endpoint_route.length === 0) {
1022
+ continue;
1023
+ }
1024
+
1025
+ const publish_meta = { method: endpoint.method };
1026
+ if (endpoint.summary) publish_meta.summary = endpoint.summary;
1027
+ if (endpoint.description) publish_meta.description = endpoint.description;
1028
+ if (endpoint.tags) publish_meta.tags = endpoint.tags;
1029
+ if (endpoint.params) publish_meta.params = endpoint.params;
1030
+ if (endpoint.returns) publish_meta.returns = endpoint.returns;
1031
+ if (endpoint.deprecated) publish_meta.deprecated = endpoint.deprecated;
1032
+ if (endpoint.operationId) publish_meta.operationId = endpoint.operationId;
1033
+ if (endpoint.raw) publish_meta.raw = endpoint.raw;
1034
+ server_instance.publish(endpoint.path || endpoint.name, endpoint.handler, publish_meta);
1035
+ }
1036
+ // ── Data query endpoints ──────────────────────────────
1037
+ // `data` accepts an object map of name → {query_fn, adapter, schema}.
1038
+ // Each entry creates a Query_Resource + Query_Publisher at /api/data/<name>.
1039
+ let data_endpoint_count = 0;
1040
+ if (serve_options.data && typeof serve_options.data === 'object') {
1041
+ const Query_Publisher = require('./publishers/query-publisher');
1042
+ const Query_Resource = require('./resources/query-resource');
1043
+ const Array_Adapter = require('./resources/adapters/array-adapter');
1044
+
1045
+ for (const [data_name, data_spec] of Object.entries(serve_options.data)) {
1046
+ if (!data_spec) continue;
1047
+
1048
+ let query_fn;
1049
+ let resource = null;
1050
+
1051
+ if (typeof data_spec.query_fn === 'function') {
1052
+ query_fn = data_spec.query_fn;
1053
+ } else if (data_spec.adapter && typeof data_spec.adapter.query === 'function') {
1054
+ resource = new Query_Resource({
1055
+ name: data_name,
1056
+ adapter: data_spec.adapter,
1057
+ schema: data_spec.schema
1058
+ });
1059
+ query_fn = (params) => resource.query(params);
1060
+ } else if (Array.isArray(data_spec.data)) {
1061
+ const adapter = new Array_Adapter({ data: data_spec.data });
1062
+ resource = new Query_Resource({
1063
+ name: data_name,
1064
+ adapter,
1065
+ schema: data_spec.schema
1066
+ });
1067
+ query_fn = (params) => resource.query(params);
1068
+ } else {
1069
+ continue;
1070
+ }
1071
+
1072
+ if (resource) {
1073
+ configured_resources.push(resource);
1074
+ if (!server_instance.configured_resources) {
1075
+ server_instance.configured_resources = configured_resources;
1076
+ }
1077
+ }
1078
+
1079
+ const data_route = ensure_route_leading_slash(`/api/data/${data_name}`);
1080
+ const publisher = new Query_Publisher({
1081
+ name: data_name,
1082
+ query_fn,
1083
+ schema: data_spec.schema
1084
+ });
1085
+ server_instance.server_router.set_route(data_route, publisher, publisher.handle_http);
1086
+ data_endpoint_count++;
1087
+ }
1088
+ }
1089
+
1090
+ // ── Swagger / OpenAPI auto-registration ──────────────
1091
+ // swagger: true → always enable
1092
+ // swagger: false → always disable
1093
+ // swagger: omitted → enable in non-production
1094
+ const swagger_option = serve_options.swagger;
1095
+ const swagger_enabled = swagger_option === true
1096
+ || (swagger_option !== false && process.env.NODE_ENV !== 'production');
1097
+
1098
+ if (swagger_enabled) {
1099
+ const swagger_options = typeof swagger_option === 'object' ? swagger_option : {};
1100
+ server_instance._register_swagger_routes({
1101
+ title: swagger_options.title || serve_options.name,
1102
+ version: swagger_options.version,
1103
+ description: swagger_options.description
1104
+ });
1105
+ }
1106
+
1107
+ const should_force_ready = !serve_options.ctrl && (
1108
+ normalized_api_endpoints.length > 0
1109
+ || data_endpoint_count > 0
1110
+ || use_manual_page_publication
1111
+ );
1112
+
410
1113
  return new Promise((resolve, reject) => {
411
1114
  let has_started = false;
412
1115
  let has_settled = false;
@@ -448,6 +1151,13 @@ module.exports = (Server) => {
448
1151
 
449
1152
  server_instance.port = actual_port;
450
1153
 
1154
+ const start_options = {
1155
+ ...(serve_options.start && typeof serve_options.start === 'object' ? serve_options.start : {})
1156
+ };
1157
+ if (typeof serve_options.on_port_conflict === 'string' && !start_options.on_port_conflict) {
1158
+ start_options.on_port_conflict = serve_options.on_port_conflict;
1159
+ }
1160
+
451
1161
  server_instance.start(actual_port, (error) => {
452
1162
  if (error) {
453
1163
  return settle(reject, error);
@@ -458,7 +1168,7 @@ module.exports = (Server) => {
458
1168
  }).catch((resource_error) => {
459
1169
  settle(reject, resource_error);
460
1170
  });
461
- });
1171
+ }, start_options);
462
1172
  };
463
1173
 
464
1174
  server_instance.on('ready', () => {
@@ -476,7 +1186,7 @@ module.exports = (Server) => {
476
1186
  });
477
1187
  });
478
1188
 
479
- if (serve_options.api && typeof serve_options.api === 'object' && !serve_options.ctrl) {
1189
+ if (should_force_ready) {
480
1190
  server_instance.raise('ready');
481
1191
  }
482
1192