@benqoder/beam 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -7,12 +7,23 @@ import { newWebSocketRpcSession } from 'capnweb';
7
7
  // - Bidirectional RPC (server can call client callbacks)
8
8
  // - Automatic reconnection
9
9
  // - Type-safe method calls
10
+ //
11
+ // SECURITY: Implements in-band authentication pattern
12
+ // - WebSocket connections start unauthenticated (PublicApi)
13
+ // - Client must call authenticate(token) to get AuthenticatedApi
14
+ // - Token is obtained from same-origin page (prevents CSWSH attacks)
10
15
  // Get endpoint from meta tag or default to /beam
11
16
  // Usage: <meta name="beam-endpoint" content="/custom-endpoint">
12
17
  function getEndpoint() {
13
18
  const meta = document.querySelector('meta[name="beam-endpoint"]');
14
19
  return meta?.getAttribute('content') ?? '/beam';
15
20
  }
21
+ // Get auth token from meta tag
22
+ // Usage: <meta name="beam-token" content="...">
23
+ function getAuthToken() {
24
+ const meta = document.querySelector('meta[name="beam-token"]');
25
+ return meta?.getAttribute('content') ?? '';
26
+ }
16
27
  let isOnline = navigator.onLine;
17
28
  let rpcSession = null;
18
29
  let connectingPromise = null;
@@ -38,27 +49,36 @@ function connect() {
38
49
  if (rpcSession) {
39
50
  return Promise.resolve(rpcSession);
40
51
  }
41
- connectingPromise = new Promise((resolve, reject) => {
52
+ connectingPromise = (async () => {
42
53
  try {
43
54
  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
44
55
  const endpoint = getEndpoint();
45
56
  const url = `${protocol}//${location.host}${endpoint}`;
46
- // Create capnweb RPC session with BeamServer type
47
- const session = newWebSocketRpcSession(url);
57
+ // Get auth token from page (proves same-origin access)
58
+ const token = getAuthToken();
59
+ if (!token) {
60
+ throw new Error('No auth token found. Ensure <meta name="beam-token" content="..."> is set.');
61
+ }
62
+ // Create capnweb RPC session - starts with PublicBeamServer
63
+ const publicSession = newWebSocketRpcSession(url);
64
+ // Authenticate to get the full BeamServer API
65
+ // This is the capnweb in-band authentication pattern
66
+ // @ts-ignore - capnweb stub methods are dynamically typed
67
+ const authenticatedSession = await publicSession.authenticate(token);
48
68
  // Register client callback for bidirectional communication
49
69
  // @ts-ignore - capnweb stub methods are dynamically typed
50
- session.registerCallback?.(handleServerEvent)?.catch?.(() => {
70
+ authenticatedSession.registerCallback?.(handleServerEvent)?.catch?.(() => {
51
71
  // Server may not support callbacks, that's ok
52
72
  });
53
- rpcSession = session;
73
+ rpcSession = authenticatedSession;
54
74
  connectingPromise = null;
55
- resolve(session);
75
+ return authenticatedSession;
56
76
  }
57
77
  catch (err) {
58
78
  connectingPromise = null;
59
- reject(err);
79
+ throw err;
60
80
  }
61
- });
81
+ })();
62
82
  return connectingPromise;
63
83
  }
64
84
  async function ensureConnected() {
@@ -85,16 +105,6 @@ const api = {
85
105
  // @ts-ignore - capnweb stub methods are dynamically typed
86
106
  return session.call(action, data);
87
107
  },
88
- async modal(modalId, params = {}) {
89
- const session = await ensureConnected();
90
- // @ts-ignore - capnweb stub methods are dynamically typed
91
- return session.modal(modalId, params);
92
- },
93
- async drawer(drawerId, params = {}) {
94
- const session = await ensureConnected();
95
- // @ts-ignore - capnweb stub methods are dynamically typed
96
- return session.drawer(drawerId, params);
97
- },
98
108
  // Direct access to RPC session for advanced usage (promise pipelining, etc.)
99
109
  async getSession() {
100
110
  return ensureConnected();
@@ -436,6 +446,72 @@ function swap(target, html, mode, trigger) {
436
446
  // Process hungry elements - auto-update elements that match IDs in response
437
447
  processHungryElements(html);
438
448
  }
449
+ /**
450
+ * Handle HTML response - supports both single string and array of HTML strings.
451
+ * Target resolution order (server wins, frontend is fallback):
452
+ * 1. Server target from comma-separated list (by index)
453
+ * - Use "!selector" to exclude that selector (blocks frontend fallback too)
454
+ * 2. Frontend target (beam-target) as fallback for remaining items
455
+ * 3. ID from HTML fragment's root element
456
+ * 4. Skip if none found
457
+ */
458
+ function handleHtmlResponse(response, frontendTarget, frontendSwap, trigger) {
459
+ if (!response.html)
460
+ return;
461
+ const htmlArray = Array.isArray(response.html) ? response.html : [response.html];
462
+ // Server targets take priority, collect exclusions
463
+ const serverTargets = response.target ? response.target.split(',').map(s => s.trim()) : [];
464
+ const excluded = new Set(serverTargets.filter(t => t.startsWith('!')).map(t => t.slice(1)));
465
+ const swapMode = response.swap || frontendSwap;
466
+ htmlArray.forEach((htmlItem, index) => {
467
+ const serverTarget = serverTargets[index];
468
+ // Skip if this is an exclusion marker
469
+ if (serverTarget?.startsWith('!')) {
470
+ return;
471
+ }
472
+ // Priority 1: Server target (by index)
473
+ let explicitTarget = serverTarget;
474
+ // Priority 2: Frontend target as fallback (only if no server target and not excluded)
475
+ if (!explicitTarget && frontendTarget && !excluded.has(frontendTarget)) {
476
+ explicitTarget = frontendTarget;
477
+ }
478
+ if (explicitTarget) {
479
+ // Explicit target provided - use normal swap
480
+ const target = $(explicitTarget);
481
+ if (target) {
482
+ swap(target, htmlItem, swapMode, trigger);
483
+ }
484
+ else {
485
+ console.warn(`[beam] Target "${explicitTarget}" not found on page, skipping`);
486
+ }
487
+ }
488
+ else {
489
+ // Priority 3: id, beam-id, or beam-item-id on root element
490
+ const temp = document.createElement('div');
491
+ temp.innerHTML = htmlItem.trim();
492
+ const rootEl = temp.firstElementChild;
493
+ // Check id first, then beam-id, then beam-item-id
494
+ const id = rootEl?.id;
495
+ const beamId = rootEl?.getAttribute('beam-id');
496
+ const beamItemId = rootEl?.getAttribute('beam-item-id');
497
+ const selector = id ? `#${id}`
498
+ : beamId ? `[beam-id="${beamId}"]`
499
+ : beamItemId ? `[beam-item-id="${beamItemId}"]`
500
+ : null;
501
+ if (selector && !excluded.has(selector)) {
502
+ const target = $(selector);
503
+ if (target) {
504
+ // Replace entire element using outerHTML (preserves styles/classes)
505
+ target.outerHTML = htmlItem.trim();
506
+ }
507
+ else {
508
+ console.warn(`[beam] Target "${selector}" (from HTML) not found on page, skipping`);
509
+ }
510
+ }
511
+ // If no id/beam-id/beam-item-id found or excluded, skip silently
512
+ }
513
+ });
514
+ }
439
515
  function parseOobSwaps(html) {
440
516
  const temp = document.createElement('div');
441
517
  temp.innerHTML = html;
@@ -452,8 +528,8 @@ function parseOobSwaps(html) {
452
528
  }
453
529
  // ============ RPC WRAPPER ============
454
530
  async function rpc(action, data, el) {
455
- const targetSelector = el.getAttribute('beam-target');
456
- const swapMode = el.getAttribute('beam-swap') || 'morph';
531
+ const frontendTarget = el.getAttribute('beam-target');
532
+ const frontendSwap = el.getAttribute('beam-swap') || 'morph';
457
533
  const opt = optimistic(el);
458
534
  const placeholder = showPlaceholder(el);
459
535
  setLoading(el, true, action, data);
@@ -464,13 +540,20 @@ async function rpc(action, data, el) {
464
540
  location.href = response.redirect;
465
541
  return;
466
542
  }
467
- // Handle HTML (if present)
468
- if (response.html && targetSelector) {
469
- const target = $(targetSelector);
470
- if (target) {
471
- swap(target, response.html, swapMode, el);
472
- }
543
+ // Handle modal (if present)
544
+ if (response.modal) {
545
+ const modalData = typeof response.modal === 'string'
546
+ ? { html: response.modal } : response.modal;
547
+ openModal(modalData.html, modalData.size || 'medium', modalData.spacing);
548
+ }
549
+ // Handle drawer (if present)
550
+ if (response.drawer) {
551
+ const drawerData = typeof response.drawer === 'string'
552
+ ? { html: response.drawer } : response.drawer;
553
+ openDrawer(drawerData.html, drawerData.position || 'right', drawerData.size || 'medium', drawerData.spacing);
473
554
  }
555
+ // Handle HTML (if present) - supports single string or array
556
+ handleHtmlResponse(response, frontendTarget, frontendSwap, el);
474
557
  // Execute script (if present)
475
558
  if (response.script) {
476
559
  executeScript(response.script);
@@ -560,8 +643,9 @@ document.addEventListener('click', async (e) => {
560
643
  closeDrawer();
561
644
  }
562
645
  });
563
- // ============ MODALS ============
564
- document.addEventListener('click', (e) => {
646
+ // ============ MODALS & DRAWERS ============
647
+ // beam-modal trigger - calls action and opens result in modal
648
+ document.addEventListener('click', async (e) => {
565
649
  const target = e.target;
566
650
  if (!target?.closest)
567
651
  return;
@@ -571,30 +655,48 @@ document.addEventListener('click', (e) => {
571
655
  // Check confirmation
572
656
  if (!checkConfirm(trigger))
573
657
  return;
574
- const modalId = trigger.getAttribute('beam-modal');
658
+ const action = trigger.getAttribute('beam-modal');
659
+ if (!action)
660
+ return;
575
661
  const size = trigger.getAttribute('beam-size') || 'medium';
576
662
  const params = getParams(trigger);
577
- if (modalId) {
578
- openModal(modalId, params, { size });
663
+ const placeholder = trigger.getAttribute('beam-placeholder');
664
+ // Show placeholder modal while loading
665
+ if (placeholder) {
666
+ openModal(placeholder, size);
579
667
  }
580
- }
581
- // Close on backdrop click
582
- if (target.matches?.('#modal-backdrop')) {
583
- closeModal();
584
- }
585
- // Close button (handles both modal and drawer)
586
- const closeBtn = target.closest('[beam-close]');
587
- if (closeBtn && !closeBtn.hasAttribute('beam-action')) {
588
- if (activeDrawer) {
589
- closeDrawer();
668
+ setLoading(trigger, true, action, params);
669
+ try {
670
+ const response = await api.call(action, params);
671
+ // Handle the response - if it returns modal, use that, otherwise use html
672
+ if (response.modal) {
673
+ const modalData = typeof response.modal === 'string'
674
+ ? { html: response.modal } : response.modal;
675
+ openModal(modalData.html, modalData.size || size, modalData.spacing);
676
+ }
677
+ else if (response.html) {
678
+ // For modals, use first item if array, otherwise use as-is
679
+ const htmlStr = Array.isArray(response.html) ? response.html[0] : response.html;
680
+ if (htmlStr)
681
+ openModal(htmlStr, size);
682
+ }
683
+ // Execute script if present
684
+ if (response.script) {
685
+ executeScript(response.script);
686
+ }
590
687
  }
591
- else {
688
+ catch (err) {
592
689
  closeModal();
690
+ showToast('Failed to open modal.', 'error');
691
+ console.error('Modal error:', err);
692
+ }
693
+ finally {
694
+ setLoading(trigger, false, action, params);
593
695
  }
594
696
  }
595
697
  });
596
- // Drawer triggers
597
- document.addEventListener('click', (e) => {
698
+ // beam-drawer trigger - calls action and opens result in drawer
699
+ document.addEventListener('click', async (e) => {
598
700
  const target = e.target;
599
701
  if (!target?.closest)
600
702
  return;
@@ -604,18 +706,69 @@ document.addEventListener('click', (e) => {
604
706
  // Check confirmation
605
707
  if (!checkConfirm(trigger))
606
708
  return;
607
- const drawerId = trigger.getAttribute('beam-drawer');
709
+ const action = trigger.getAttribute('beam-drawer');
710
+ if (!action)
711
+ return;
608
712
  const position = trigger.getAttribute('beam-position') || 'right';
609
713
  const size = trigger.getAttribute('beam-size') || 'medium';
610
714
  const params = getParams(trigger);
611
- if (drawerId) {
612
- openDrawer(drawerId, params, { position, size });
715
+ const placeholder = trigger.getAttribute('beam-placeholder');
716
+ // Show placeholder drawer while loading
717
+ if (placeholder) {
718
+ openDrawer(placeholder, position, size);
719
+ }
720
+ setLoading(trigger, true, action, params);
721
+ try {
722
+ const response = await api.call(action, params);
723
+ // Handle the response - if it returns drawer, use that, otherwise use html
724
+ if (response.drawer) {
725
+ const drawerData = typeof response.drawer === 'string'
726
+ ? { html: response.drawer } : response.drawer;
727
+ openDrawer(drawerData.html, drawerData.position || position, drawerData.size || size, drawerData.spacing);
728
+ }
729
+ else if (response.html) {
730
+ // For drawers, use first item if array, otherwise use as-is
731
+ const htmlStr = Array.isArray(response.html) ? response.html[0] : response.html;
732
+ if (htmlStr)
733
+ openDrawer(htmlStr, position, size);
734
+ }
735
+ // Execute script if present
736
+ if (response.script) {
737
+ executeScript(response.script);
738
+ }
739
+ }
740
+ catch (err) {
741
+ closeDrawer();
742
+ showToast('Failed to open drawer.', 'error');
743
+ console.error('Drawer error:', err);
744
+ }
745
+ finally {
746
+ setLoading(trigger, false, action, params);
613
747
  }
614
748
  }
749
+ });
750
+ // Close handlers
751
+ document.addEventListener('click', (e) => {
752
+ const target = e.target;
753
+ if (!target?.closest)
754
+ return;
615
755
  // Close on backdrop click
756
+ if (target.matches?.('#modal-backdrop')) {
757
+ closeModal();
758
+ }
616
759
  if (target.matches?.('#drawer-backdrop')) {
617
760
  closeDrawer();
618
761
  }
762
+ // Close button (handles both modal and drawer)
763
+ const closeBtn = target.closest('[beam-close]');
764
+ if (closeBtn && !closeBtn.hasAttribute('beam-action')) {
765
+ if (activeDrawer) {
766
+ closeDrawer();
767
+ }
768
+ else {
769
+ closeModal();
770
+ }
771
+ }
619
772
  });
620
773
  document.addEventListener('keydown', (e) => {
621
774
  if (e.key === 'Escape') {
@@ -627,33 +780,30 @@ document.addEventListener('keydown', (e) => {
627
780
  }
628
781
  }
629
782
  });
630
- async function openModal(id, params = {}, options = { size: 'medium' }) {
631
- try {
632
- const html = await api.modal(id, params);
633
- let backdrop = $('#modal-backdrop');
634
- if (!backdrop) {
635
- backdrop = document.createElement('div');
636
- backdrop.id = 'modal-backdrop';
637
- document.body.appendChild(backdrop);
638
- }
639
- const { size } = options;
640
- backdrop.innerHTML = `
641
- <div id="modal-content" role="dialog" aria-modal="true" data-size="${size}">
642
- ${html}
643
- </div>
644
- `;
645
- backdrop.offsetHeight;
646
- backdrop.classList.add('open');
647
- document.body.classList.add('modal-open');
648
- activeModal = $('#modal-content');
649
- const autoFocus = activeModal?.querySelector('[autofocus]');
650
- const firstInput = activeModal?.querySelector('input, button, textarea, select');
651
- (autoFocus || firstInput)?.focus();
652
- }
653
- catch (err) {
654
- showToast('Failed to open modal.', 'error');
655
- console.error('Modal error:', err);
783
+ function openModal(html, size = 'medium', spacing) {
784
+ // Close any existing drawer first (modal takes priority)
785
+ if (activeDrawer) {
786
+ closeDrawer();
656
787
  }
788
+ let backdrop = $('#modal-backdrop');
789
+ if (!backdrop) {
790
+ backdrop = document.createElement('div');
791
+ backdrop.id = 'modal-backdrop';
792
+ document.body.appendChild(backdrop);
793
+ }
794
+ const style = spacing !== undefined ? `padding: ${spacing}px;` : '';
795
+ backdrop.innerHTML = `
796
+ <div id="modal-content" role="dialog" aria-modal="true" data-size="${size}" style="${style}">
797
+ ${html}
798
+ </div>
799
+ `;
800
+ backdrop.offsetHeight;
801
+ backdrop.classList.add('open');
802
+ document.body.classList.add('modal-open');
803
+ activeModal = $('#modal-content');
804
+ const autoFocus = activeModal?.querySelector('[autofocus]');
805
+ const firstInput = activeModal?.querySelector('input, button, textarea, select');
806
+ (autoFocus || firstInput)?.focus();
657
807
  }
658
808
  function closeModal() {
659
809
  const backdrop = $('#modal-backdrop');
@@ -666,34 +816,31 @@ function closeModal() {
666
816
  document.body.classList.remove('modal-open');
667
817
  activeModal = null;
668
818
  }
669
- async function openDrawer(id, params = {}, options) {
670
- try {
671
- const html = await api.drawer(id, params);
672
- let backdrop = $('#drawer-backdrop');
673
- if (!backdrop) {
674
- backdrop = document.createElement('div');
675
- backdrop.id = 'drawer-backdrop';
676
- document.body.appendChild(backdrop);
677
- }
678
- // Set position and size as data attributes for CSS styling
679
- const { position, size } = options;
680
- backdrop.innerHTML = `
681
- <div id="drawer-content" role="dialog" aria-modal="true" data-position="${position}" data-size="${size}">
682
- ${html}
683
- </div>
684
- `;
685
- backdrop.offsetHeight; // Force reflow
686
- backdrop.classList.add('open');
687
- document.body.classList.add('drawer-open');
688
- activeDrawer = $('#drawer-content');
689
- const autoFocus = activeDrawer?.querySelector('[autofocus]');
690
- const firstInput = activeDrawer?.querySelector('input, button, textarea, select');
691
- (autoFocus || firstInput)?.focus();
692
- }
693
- catch (err) {
694
- showToast('Failed to open drawer.', 'error');
695
- console.error('Drawer error:', err);
819
+ // ============ DRAWERS ============
820
+ function openDrawer(html, position = 'right', size = 'medium', spacing) {
821
+ // Close any existing modal first (drawer takes priority)
822
+ if (activeModal) {
823
+ closeModal();
696
824
  }
825
+ let backdrop = $('#drawer-backdrop');
826
+ if (!backdrop) {
827
+ backdrop = document.createElement('div');
828
+ backdrop.id = 'drawer-backdrop';
829
+ document.body.appendChild(backdrop);
830
+ }
831
+ const style = spacing !== undefined ? `padding: ${spacing}px;` : '';
832
+ backdrop.innerHTML = `
833
+ <div id="drawer-content" role="dialog" aria-modal="true" data-position="${position}" data-size="${size}" style="${style}">
834
+ ${html}
835
+ </div>
836
+ `;
837
+ backdrop.offsetHeight; // Force reflow
838
+ backdrop.classList.add('open');
839
+ document.body.classList.add('drawer-open');
840
+ activeDrawer = $('#drawer-content');
841
+ const autoFocus = activeDrawer?.querySelector('[autofocus]');
842
+ const firstInput = activeDrawer?.querySelector('input, button, textarea, select');
843
+ (autoFocus || firstInput)?.focus();
697
844
  }
698
845
  function closeDrawer() {
699
846
  const backdrop = $('#drawer-backdrop');
@@ -1102,9 +1249,14 @@ const infiniteObserver = new IntersectionObserver(async (entries) => {
1102
1249
  setLoading(sentinel, true, action, params);
1103
1250
  try {
1104
1251
  const response = await api.call(action, params);
1105
- const target = $(targetSelector);
1106
- if (target && response.html) {
1107
- swap(target, response.html, swapMode, sentinel);
1252
+ if (response.html) {
1253
+ // For infinite scroll, always use the specified target (not auto-detect from HTML)
1254
+ const target = $(targetSelector);
1255
+ if (target) {
1256
+ // Handle single html string for infinite scroll (array not typically used here)
1257
+ const htmlStr = Array.isArray(response.html) ? response.html.join('') : response.html;
1258
+ swap(target, htmlStr, swapMode, sentinel);
1259
+ }
1108
1260
  // Save scroll state after content is loaded
1109
1261
  requestAnimationFrame(() => {
1110
1262
  saveScrollState(targetSelector, action);
@@ -1163,9 +1315,14 @@ document.addEventListener('click', async (e) => {
1163
1315
  setLoading(trigger, true, action, params);
1164
1316
  try {
1165
1317
  const response = await api.call(action, params);
1166
- const targetEl = $(targetSelector);
1167
- if (targetEl && response.html) {
1168
- swap(targetEl, response.html, swapMode, trigger);
1318
+ if (response.html) {
1319
+ // For load more, always use the specified target (not auto-detect from HTML)
1320
+ const targetEl = $(targetSelector);
1321
+ if (targetEl) {
1322
+ // Handle single html string for load more (array not typically used here)
1323
+ const htmlStr = Array.isArray(response.html) ? response.html.join('') : response.html;
1324
+ swap(targetEl, htmlStr, swapMode, trigger);
1325
+ }
1169
1326
  // Save scroll state after content is loaded
1170
1327
  requestAnimationFrame(() => {
1171
1328
  saveScrollState(targetSelector, action);
@@ -1349,13 +1506,8 @@ document.addEventListener('click', async (e) => {
1349
1506
  location.href = response.redirect;
1350
1507
  return;
1351
1508
  }
1352
- // Handle HTML (if present)
1353
- if (response.html && targetSelector) {
1354
- const target = $(targetSelector);
1355
- if (target) {
1356
- swap(target, response.html, swapMode, link);
1357
- }
1358
- }
1509
+ // Handle HTML (if present) - supports single string or array
1510
+ handleHtmlResponse(response, targetSelector, swapMode, link);
1359
1511
  // Execute script (if present)
1360
1512
  if (response.script) {
1361
1513
  executeScript(response.script);
@@ -1405,13 +1557,20 @@ document.addEventListener('submit', async (e) => {
1405
1557
  location.href = response.redirect;
1406
1558
  return;
1407
1559
  }
1408
- // Handle HTML (if present)
1409
- if (response.html && targetSelector) {
1410
- const target = $(targetSelector);
1411
- if (target) {
1412
- swap(target, response.html, swapMode);
1413
- }
1560
+ // Handle modal (if present)
1561
+ if (response.modal) {
1562
+ const modalData = typeof response.modal === 'string'
1563
+ ? { html: response.modal } : response.modal;
1564
+ openModal(modalData.html, modalData.size || 'medium', modalData.spacing);
1414
1565
  }
1566
+ // Handle drawer (if present)
1567
+ if (response.drawer) {
1568
+ const drawerData = typeof response.drawer === 'string'
1569
+ ? { html: response.drawer } : response.drawer;
1570
+ openDrawer(drawerData.html, drawerData.position || 'right', drawerData.size || 'medium', drawerData.spacing);
1571
+ }
1572
+ // Handle HTML (if present) - supports single string or array
1573
+ handleHtmlResponse(response, targetSelector, swapMode);
1415
1574
  // Execute script (if present)
1416
1575
  if (response.script) {
1417
1576
  executeScript(response.script);
@@ -1460,7 +1619,10 @@ function setupValidation(el) {
1460
1619
  if (response.html) {
1461
1620
  const target = $(targetSelector);
1462
1621
  if (target) {
1463
- morph(target, response.html);
1622
+ // For validation, use first item if array, otherwise use as-is
1623
+ const htmlStr = Array.isArray(response.html) ? response.html[0] : response.html;
1624
+ if (htmlStr)
1625
+ morph(target, htmlStr);
1464
1626
  }
1465
1627
  }
1466
1628
  // Execute script (if present)
@@ -1510,7 +1672,10 @@ const deferObserver = new IntersectionObserver(async (entries) => {
1510
1672
  if (response.html) {
1511
1673
  const target = targetSelector ? $(targetSelector) : el;
1512
1674
  if (target) {
1513
- swap(target, response.html, swapMode);
1675
+ // For deferred loading, use first item if array, otherwise use as-is
1676
+ const htmlStr = Array.isArray(response.html) ? response.html[0] : response.html;
1677
+ if (htmlStr)
1678
+ swap(target, htmlStr, swapMode);
1514
1679
  }
1515
1680
  }
1516
1681
  // Execute script (if present)
@@ -1566,7 +1731,10 @@ function startPolling(el) {
1566
1731
  if (response.html) {
1567
1732
  const target = targetSelector ? $(targetSelector) : el;
1568
1733
  if (target) {
1569
- swap(target, response.html, swapMode);
1734
+ // For polling, use first item if array, otherwise use as-is
1735
+ const htmlStr = Array.isArray(response.html) ? response.html[0] : response.html;
1736
+ if (htmlStr)
1737
+ swap(target, htmlStr, swapMode);
1570
1738
  }
1571
1739
  }
1572
1740
  // Execute script (if present)
@@ -1822,17 +1990,27 @@ window.beam = new Proxy(beamUtils, {
1822
1990
  location.href = response.redirect;
1823
1991
  return response;
1824
1992
  }
1993
+ // Handle modal (if present)
1994
+ if (response.modal) {
1995
+ const modalData = typeof response.modal === 'string'
1996
+ ? { html: response.modal } : response.modal;
1997
+ openModal(modalData.html, modalData.size || 'medium', modalData.spacing);
1998
+ }
1999
+ // Handle drawer (if present)
2000
+ if (response.drawer) {
2001
+ const drawerData = typeof response.drawer === 'string'
2002
+ ? { html: response.drawer } : response.drawer;
2003
+ openDrawer(drawerData.html, drawerData.position || 'right', drawerData.size || 'medium', drawerData.spacing);
2004
+ }
1825
2005
  // Normalize options: string is shorthand for { target: string }
1826
2006
  const opts = typeof options === 'string'
1827
2007
  ? { target: options }
1828
2008
  : (options || {});
1829
- // Handle HTML swap if target provided
1830
- if (response.html && opts.target) {
1831
- const targetEl = document.querySelector(opts.target);
1832
- if (targetEl) {
1833
- swap(targetEl, response.html, opts.swap || 'morph');
1834
- }
1835
- }
2009
+ // Server target/swap override frontend options
2010
+ const targetSelector = response.target || opts.target || null;
2011
+ const swapMode = response.swap || opts.swap || 'morph';
2012
+ // Handle HTML swap - supports single string or array
2013
+ handleHtmlResponse(response, targetSelector, swapMode);
1836
2014
  // Execute script if present
1837
2015
  if (response.script) {
1838
2016
  executeScript(response.script);
package/dist/collect.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ActionHandler, ModalHandler, DrawerHandler } from './types';
1
+ import type { ActionHandler } from './types';
2
2
  /**
3
3
  * Type for glob import results from import.meta.glob
4
4
  */
@@ -18,51 +18,5 @@ type GlobImport = Record<string, Record<string, unknown>>;
18
18
  * - `./actions/cart.tsx` exports `addToCart` → `addToCart`
19
19
  */
20
20
  export declare function collectActions<TEnv = object>(glob: GlobImport): Record<string, ActionHandler<TEnv>>;
21
- /**
22
- * Collects modal handlers from glob imports.
23
- *
24
- * @example
25
- * ```typescript
26
- * const modals = collectModals<Env>(
27
- * import.meta.glob('./modals/*.tsx', { eager: true })
28
- * )
29
- * ```
30
- */
31
- export declare function collectModals<TEnv = object>(glob: GlobImport): Record<string, ModalHandler<TEnv>>;
32
- /**
33
- * Collects drawer handlers from glob imports.
34
- *
35
- * @example
36
- * ```typescript
37
- * const drawers = collectDrawers<Env>(
38
- * import.meta.glob('./drawers/*.tsx', { eager: true })
39
- * )
40
- * ```
41
- */
42
- export declare function collectDrawers<TEnv = object>(glob: GlobImport): Record<string, DrawerHandler<TEnv>>;
43
- /**
44
- * Collects all handlers (actions, modals, drawers) from glob imports.
45
- * This is a convenience function that collects all three at once.
46
- *
47
- * @example
48
- * ```typescript
49
- * const { actions, modals, drawers } = collectHandlers<Env>({
50
- * actions: import.meta.glob('./actions/*.tsx', { eager: true }),
51
- * modals: import.meta.glob('./modals/*.tsx', { eager: true }),
52
- * drawers: import.meta.glob('./drawers/*.tsx', { eager: true }),
53
- * })
54
- *
55
- * export const beam = createBeam<Env>({ actions, modals, drawers })
56
- * ```
57
- */
58
- export declare function collectHandlers<TEnv = object>(globs: {
59
- actions?: GlobImport;
60
- modals?: GlobImport;
61
- drawers?: GlobImport;
62
- }): {
63
- actions: Record<string, ActionHandler<TEnv>>;
64
- modals: Record<string, ModalHandler<TEnv>>;
65
- drawers: Record<string, DrawerHandler<TEnv>>;
66
- };
67
21
  export {};
68
22
  //# sourceMappingURL=collect.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"collect.d.ts","sourceRoot":"","sources":["../src/collect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAEzE;;GAEG;AACH,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;AAEzD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,IAAI,GAAG,MAAM,EAC1C,IAAI,EAAE,UAAU,GACf,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAarC;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,GAAG,MAAM,EACzC,IAAI,EAAE,UAAU,GACf,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC,CAYpC;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,IAAI,GAAG,MAAM,EAC1C,IAAI,EAAE,UAAU,GACf,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAYrC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,IAAI,GAAG,MAAM,EAAE,KAAK,EAAE;IACpD,OAAO,CAAC,EAAE,UAAU,CAAA;IACpB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,OAAO,CAAC,EAAE,UAAU,CAAA;CACrB,GAAG;IACF,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAAA;IAC5C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC,CAAA;IAC1C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAAA;CAC7C,CAMA"}
1
+ {"version":3,"file":"collect.d.ts","sourceRoot":"","sources":["../src/collect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C;;GAEG;AACH,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;AAEzD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,IAAI,GAAG,MAAM,EAC1C,IAAI,EAAE,UAAU,GACf,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAarC"}