@nuraly/lumenjs 0.1.4 → 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.
Files changed (76) hide show
  1. package/dist/auth/native-auth.d.ts +9 -0
  2. package/dist/auth/native-auth.js +49 -2
  3. package/dist/auth/routes/login.js +24 -1
  4. package/dist/auth/routes/totp.d.ts +22 -0
  5. package/dist/auth/routes/totp.js +232 -0
  6. package/dist/auth/routes.js +14 -0
  7. package/dist/auth/token.js +2 -2
  8. package/dist/build/build-server.d.ts +2 -1
  9. package/dist/build/build-server.js +10 -1
  10. package/dist/build/build.js +13 -4
  11. package/dist/build/scan.d.ts +1 -0
  12. package/dist/build/scan.js +2 -1
  13. package/dist/build/serve.js +131 -11
  14. package/dist/dev-server/config.js +18 -1
  15. package/dist/dev-server/index-html.d.ts +1 -0
  16. package/dist/dev-server/index-html.js +4 -1
  17. package/dist/dev-server/plugins/vite-plugin-routes.js +3 -2
  18. package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +34 -6
  19. package/dist/dev-server/server.js +146 -88
  20. package/dist/dev-server/ssr-render.js +10 -2
  21. package/dist/editor/ai/backend.js +11 -2
  22. package/dist/editor/ai/deepseek-client.d.ts +7 -0
  23. package/dist/editor/ai/deepseek-client.js +113 -0
  24. package/dist/editor/ai/opencode-client.d.ts +1 -1
  25. package/dist/editor/ai/opencode-client.js +21 -47
  26. package/dist/editor/ai-chat-panel.js +27 -1
  27. package/dist/editor/editor-bridge.js +2 -1
  28. package/dist/editor/overlay-hmr.js +2 -1
  29. package/dist/runtime/app-shell.d.ts +1 -1
  30. package/dist/runtime/app-shell.js +1 -0
  31. package/dist/runtime/island.d.ts +16 -0
  32. package/dist/runtime/island.js +80 -0
  33. package/dist/runtime/router-hydration.js +9 -2
  34. package/dist/runtime/router.d.ts +3 -1
  35. package/dist/runtime/router.js +49 -1
  36. package/dist/runtime/webrtc.d.ts +44 -0
  37. package/dist/runtime/webrtc.js +263 -13
  38. package/dist/shared/dom-shims.js +4 -2
  39. package/dist/shared/types.d.ts +1 -0
  40. package/dist/storage/adapters/s3.js +6 -3
  41. package/package.json +33 -7
  42. package/templates/social/api/posts/[id].ts +0 -14
  43. package/templates/social/api/posts.ts +0 -11
  44. package/templates/social/api/profile/[username].ts +0 -10
  45. package/templates/social/api/upload.ts +0 -19
  46. package/templates/social/data/migrations/001_init.sql +0 -78
  47. package/templates/social/data/migrations/002_add_image_url.sql +0 -1
  48. package/templates/social/data/migrations/003_auth.sql +0 -7
  49. package/templates/social/docs/architecture.md +0 -76
  50. package/templates/social/docs/components.md +0 -100
  51. package/templates/social/docs/data.md +0 -89
  52. package/templates/social/docs/pages.md +0 -96
  53. package/templates/social/docs/theming.md +0 -52
  54. package/templates/social/lib/media.ts +0 -130
  55. package/templates/social/lumenjs.auth.ts +0 -21
  56. package/templates/social/lumenjs.config.ts +0 -3
  57. package/templates/social/package.json +0 -5
  58. package/templates/social/pages/_layout.ts +0 -239
  59. package/templates/social/pages/apps/[id].ts +0 -173
  60. package/templates/social/pages/apps/index.ts +0 -116
  61. package/templates/social/pages/auth/login.ts +0 -92
  62. package/templates/social/pages/bookmarks.ts +0 -57
  63. package/templates/social/pages/explore.ts +0 -73
  64. package/templates/social/pages/index.ts +0 -351
  65. package/templates/social/pages/messages.ts +0 -298
  66. package/templates/social/pages/new.ts +0 -77
  67. package/templates/social/pages/notifications.ts +0 -73
  68. package/templates/social/pages/post/[id].ts +0 -124
  69. package/templates/social/pages/profile/[username].ts +0 -100
  70. package/templates/social/pages/settings/accessibility.ts +0 -153
  71. package/templates/social/pages/settings/account.ts +0 -260
  72. package/templates/social/pages/settings/help.ts +0 -141
  73. package/templates/social/pages/settings/language.ts +0 -103
  74. package/templates/social/pages/settings/privacy.ts +0 -183
  75. package/templates/social/pages/settings/security.ts +0 -133
  76. package/templates/social/pages/settings.ts +0 -185
@@ -60,7 +60,8 @@ export function reselectAfterHmr() {
60
60
  }
61
61
  export function setupHmrListener() {
62
62
  try {
63
- const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`, 'vite-hmr');
63
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '');
64
+ const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${base}`, 'vite-hmr');
64
65
  ws.addEventListener('message', (ev) => {
65
66
  try {
66
67
  const msg = JSON.parse(ev.data);
@@ -1 +1 @@
1
- export {};
1
+ import './island.js';
@@ -1,5 +1,6 @@
1
1
  import { routes } from 'virtual:lumenjs-routes';
2
2
  import { NkRouter } from './router.js';
3
+ import './island.js';
3
4
  function getDefaultStrategy() {
4
5
  const el = document.getElementById('__nk_prefetch__');
5
6
  if (el) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * <nk-island> — Islands Architecture hydration wrapper.
3
+ *
4
+ * Strategies:
5
+ * client:load — import module immediately on page load
6
+ * client:visible — import module when the element scrolls into view
7
+ * client:idle — import module when the browser is idle (requestIdleCallback)
8
+ * client:media — import module when a media query matches (value = query string)
9
+ *
10
+ * The `import` attribute specifies the module path to load.
11
+ */
12
+ declare class NkIsland extends HTMLElement {
13
+ private _loaded;
14
+ connectedCallback(): void;
15
+ private _hydrate;
16
+ }
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ /**
3
+ * <nk-island> — Islands Architecture hydration wrapper.
4
+ *
5
+ * Strategies:
6
+ * client:load — import module immediately on page load
7
+ * client:visible — import module when the element scrolls into view
8
+ * client:idle — import module when the browser is idle (requestIdleCallback)
9
+ * client:media — import module when a media query matches (value = query string)
10
+ *
11
+ * The `import` attribute specifies the module path to load.
12
+ */
13
+ class NkIsland extends HTMLElement {
14
+ constructor() {
15
+ super(...arguments);
16
+ this._loaded = false;
17
+ }
18
+ connectedCallback() {
19
+ const importPath = this.getAttribute('import');
20
+ if (!importPath || this._loaded)
21
+ return;
22
+ if (this.hasAttribute('client:load')) {
23
+ this._hydrate(importPath);
24
+ }
25
+ else if (this.hasAttribute('client:visible')) {
26
+ const observer = new IntersectionObserver((entries) => {
27
+ if (entries[0]?.isIntersecting) {
28
+ observer.disconnect();
29
+ this._hydrate(importPath);
30
+ }
31
+ }, { rootMargin: '200px' });
32
+ observer.observe(this);
33
+ }
34
+ else if (this.hasAttribute('client:idle')) {
35
+ const cb = () => this._hydrate(importPath);
36
+ if ('requestIdleCallback' in window) {
37
+ window.requestIdleCallback(cb);
38
+ }
39
+ else {
40
+ setTimeout(cb, 200);
41
+ }
42
+ }
43
+ else if (this.hasAttribute('client:media')) {
44
+ const query = this.getAttribute('client:media') || '';
45
+ const mql = window.matchMedia(query);
46
+ const handler = () => {
47
+ if (mql.matches) {
48
+ mql.removeEventListener('change', handler);
49
+ this._hydrate(importPath);
50
+ }
51
+ };
52
+ if (mql.matches) {
53
+ this._hydrate(importPath);
54
+ }
55
+ else {
56
+ mql.addEventListener('change', handler);
57
+ }
58
+ }
59
+ }
60
+ _hydrate(importPath) {
61
+ if (this._loaded)
62
+ return;
63
+ this._loaded = true;
64
+ // Check global island registry (populated by pages with island imports)
65
+ const registry = window.__nk_islands;
66
+ const loader = registry?.[importPath];
67
+ const promise = loader
68
+ ? loader()
69
+ : import(/* @vite-ignore */ importPath);
70
+ promise.then(() => {
71
+ this.setAttribute('data-hydrated', '');
72
+ this.dispatchEvent(new Event('island-hydrated', { bubbles: true, composed: true }));
73
+ }).catch((err) => {
74
+ console.error(`[nk-island] Failed to load module: ${importPath}`, err);
75
+ });
76
+ }
77
+ }
78
+ if (!customElements.get('nk-island')) {
79
+ customElements.define('nk-island', NkIsland);
80
+ }
@@ -25,9 +25,16 @@ export async function hydrateInitialRoute(routes, outlet, matchRoute, onHydrated
25
25
  catch { }
26
26
  authScript.remove();
27
27
  }
28
- // Strip locale prefix for route matching (routes are locale-agnostic)
28
+ // Strip Vite base path and locale prefix for route matching
29
+ let matchPath = location.pathname;
30
+ const base = import.meta.env?.BASE_URL;
31
+ if (base && base !== '/' && matchPath.startsWith(base)) {
32
+ matchPath = '/' + matchPath.slice(base.length);
33
+ }
29
34
  const config = getI18nConfig();
30
- const matchPath = config ? stripLocalePrefix(location.pathname) : location.pathname;
35
+ if (config) {
36
+ matchPath = stripLocalePrefix(matchPath);
37
+ }
31
38
  const match = matchRoute(matchPath);
32
39
  if (!match)
33
40
  return;
@@ -10,6 +10,7 @@ export interface Route {
10
10
  tagName: string;
11
11
  hasLoader?: boolean;
12
12
  hasSubscribe?: boolean;
13
+ hasSocket?: boolean;
13
14
  hasMeta?: boolean;
14
15
  load?: () => Promise<any>;
15
16
  layouts?: LayoutInfo[];
@@ -27,6 +28,7 @@ export declare class NkRouter {
27
28
  private currentTag;
28
29
  private currentLayoutTags;
29
30
  private subscriptions;
31
+ private _sockets;
30
32
  private siteTitle;
31
33
  params: Record<string, string>;
32
34
  constructor(routes: Route[], outlet: HTMLElement, hydrate?: boolean);
@@ -48,7 +50,7 @@ export declare class NkRouter {
48
50
  private updatePageMeta;
49
51
  private handleLinkClick;
50
52
  prefetch(fullPath: string): Promise<void>;
51
- /** Strip locale prefix from a path for internal route matching. */
53
+ /** Strip base and locale prefix from a path for internal route matching. */
52
54
  private stripLocale;
53
55
  /** Prepend locale prefix for browser-facing URLs. */
54
56
  private withLocale;
@@ -13,6 +13,7 @@ export class NkRouter {
13
13
  this.currentTag = null;
14
14
  this.currentLayoutTags = [];
15
15
  this.subscriptions = [];
16
+ this._sockets = new Map();
16
17
  this.params = {};
17
18
  this.outlet = outlet;
18
19
  this.siteTitle = document.title || 'LumenJS App';
@@ -100,6 +101,10 @@ export class NkRouter {
100
101
  es.close();
101
102
  }
102
103
  this.subscriptions = [];
104
+ // Disconnect any active socket.io connections
105
+ for (const [, sock] of this._sockets)
106
+ sock.disconnect();
107
+ this._sockets.clear();
103
108
  }
104
109
  async navigate(fullPath, pushState = true) {
105
110
  this.cleanupSubscriptions();
@@ -169,6 +174,42 @@ export class NkRouter {
169
174
  };
170
175
  this.subscriptions.push(es);
171
176
  }
177
+ // Page socket (bidirectional via Socket.IO)
178
+ if (match.route.hasSocket) {
179
+ import('socket.io-client').then(({ io }) => {
180
+ const ns = `/nk${pathname === '/' ? '/index' : pathname}`;
181
+ const query = {};
182
+ if (Object.keys(match.params).length > 0)
183
+ query.__params = JSON.stringify(match.params);
184
+ try {
185
+ const locale = getLocale();
186
+ if (locale)
187
+ query.__locale = locale;
188
+ }
189
+ catch { }
190
+ const existing = this._sockets.get(pathname);
191
+ if (existing)
192
+ existing.disconnect();
193
+ const socket = io(ns, { path: '/__nk_socketio/', query });
194
+ this._sockets.set(pathname, socket);
195
+ const injectEmit = () => {
196
+ const pageEl = this.findPageElement(match.route.tagName);
197
+ if (pageEl) {
198
+ pageEl.emit = (event, payload) => {
199
+ socket.emit(`nk:${event}`, payload);
200
+ };
201
+ }
202
+ };
203
+ injectEmit();
204
+ socket.on('nk:data', (data) => {
205
+ const pageEl = this.findPageElement(match.route.tagName);
206
+ if (pageEl) {
207
+ pageEl.liveData = data;
208
+ injectEmit();
209
+ }
210
+ });
211
+ }).catch(err => console.error('[NkRouter] Socket connection failed:', err));
212
+ }
172
213
  // Layout subscriptions
173
214
  for (const layout of layouts) {
174
215
  if (layout.hasSubscribe) {
@@ -327,6 +368,8 @@ export class NkRouter {
327
368
  }
328
369
  const title = pageTitle || this.siteTitle;
329
370
  document.title = title;
371
+ // Let layout components adjust title (e.g. prepend badge counts)
372
+ window.dispatchEvent(new CustomEvent('nk-title-updated'));
330
373
  // Announce route change to screen readers
331
374
  const announcer = document.getElementById('nk-route-announcer');
332
375
  if (announcer) {
@@ -371,8 +414,13 @@ export class NkRouter {
371
414
  ...layouts.map(l => l.hasLoader ? prefetchLayoutLoaderData(l.loaderPath || '').catch(() => { }) : undefined),
372
415
  ]);
373
416
  }
374
- /** Strip locale prefix from a path for internal route matching. */
417
+ /** Strip base and locale prefix from a path for internal route matching. */
375
418
  stripLocale(path) {
419
+ // Strip Vite base path (e.g. /__app_dev/{id}/) before route matching
420
+ const base = import.meta.env?.BASE_URL;
421
+ if (base && base !== '/' && path.startsWith(base)) {
422
+ path = '/' + path.slice(base.length);
423
+ }
376
424
  const config = getI18nConfig();
377
425
  return config ? stripLocalePrefix(path) : path;
378
426
  }
@@ -17,6 +17,7 @@ export declare class WebRTCManager {
17
17
  private _callbacks;
18
18
  private _pendingCandidates;
19
19
  private _role;
20
+ private _screenStream;
20
21
  constructor(callbacks: WebRTCCallbacks, iceServers?: RTCIceServer[]);
21
22
  private _createPeerConnection;
22
23
  get localStream(): MediaStream | null;
@@ -45,3 +46,46 @@ export declare class WebRTCManager {
45
46
  /** Clean up everything */
46
47
  destroy(): void;
47
48
  }
49
+ export interface GroupWebRTCCallbacks {
50
+ onLocalStream: (stream: MediaStream) => void;
51
+ onRemoteStream: (userId: string, stream: MediaStream) => void;
52
+ onRemoteStreamRemoved: (userId: string) => void;
53
+ onConnectionStateChange: (userId: string, state: RTCPeerConnectionState) => void;
54
+ onIceCandidate: (toUserId: string, candidate: RTCIceCandidate) => void;
55
+ onError: (error: Error) => void;
56
+ }
57
+ export declare class GroupWebRTCManager {
58
+ private _peers;
59
+ private _localStream;
60
+ private _callbacks;
61
+ private _iceServers;
62
+ private _screenStream;
63
+ constructor(callbacks: GroupWebRTCCallbacks, iceServers?: RTCIceServer[]);
64
+ get localStream(): MediaStream | null;
65
+ get peerCount(): number;
66
+ getRemoteStream(userId: string): MediaStream | null;
67
+ getRemoteStreams(): Map<string, MediaStream>;
68
+ /** Acquire local media — call once before adding peers */
69
+ startLocalMedia(video?: boolean, audio?: boolean): Promise<MediaStream>;
70
+ /** Create a peer connection for a remote user and optionally create an offer */
71
+ addPeer(userId: string, isCaller: boolean): Promise<string | void>;
72
+ /** Remove and close a peer connection */
73
+ removePeer(userId: string): void;
74
+ /** Handle an SDP offer from a remote peer and return an answer */
75
+ handleOffer(fromUserId: string, sdp: string): Promise<string>;
76
+ /** Handle an SDP answer from a remote peer */
77
+ handleAnswer(fromUserId: string, sdp: string): Promise<void>;
78
+ /** Add an ICE candidate for a specific peer */
79
+ addIceCandidate(fromUserId: string, candidate: string, sdpMLineIndex: number | null, sdpMid: string | null): Promise<void>;
80
+ private _flushPendingCandidates;
81
+ /** Toggle audio for all peers */
82
+ setAudioEnabled(enabled: boolean): void;
83
+ /** Toggle video for all peers */
84
+ setVideoEnabled(enabled: boolean): void;
85
+ /** Replace camera track with screen share on all peers */
86
+ startScreenShare(): Promise<MediaStream>;
87
+ /** Revert from screen share back to camera on all peers */
88
+ stopScreenShare(): Promise<void>;
89
+ /** Clean up everything */
90
+ destroy(): void;
91
+ }
@@ -2,10 +2,15 @@
2
2
  * WebRTC peer connection manager.
3
3
  * Wraps RTCPeerConnection and wires to the LumenJS communication SDK signaling.
4
4
  */
5
- const ICE_SERVERS = [
5
+ const DEFAULT_ICE_SERVERS = [
6
6
  { urls: 'stun:stun.l.google.com:19302' },
7
7
  { urls: 'stun:stun1.l.google.com:19302' },
8
8
  ];
9
+ function getIceServers(custom) {
10
+ return custom
11
+ || (typeof window !== 'undefined' && window.__NK_ICE_SERVERS)
12
+ || DEFAULT_ICE_SERVERS;
13
+ }
9
14
  export class WebRTCManager {
10
15
  constructor(callbacks, iceServers) {
11
16
  this._pc = null;
@@ -13,8 +18,9 @@ export class WebRTCManager {
13
18
  this._remoteStream = null;
14
19
  this._pendingCandidates = [];
15
20
  this._role = 'caller';
21
+ this._screenStream = null;
16
22
  this._callbacks = callbacks;
17
- this._createPeerConnection(iceServers || ICE_SERVERS);
23
+ this._createPeerConnection(getIceServers(iceServers));
18
24
  }
19
25
  _createPeerConnection(iceServers) {
20
26
  this._pc = new RTCPeerConnection({ iceServers });
@@ -42,9 +48,22 @@ export class WebRTCManager {
42
48
  get connectionState() { return this._pc?.connectionState ?? null; }
43
49
  /** Acquire local media (camera/mic) and add tracks to the peer connection */
44
50
  async startLocalMedia(video = true, audio = true) {
51
+ if (!navigator.mediaDevices?.getUserMedia) {
52
+ const err = new Error('Media devices unavailable — HTTPS is required for calls');
53
+ this._callbacks.onError(err);
54
+ throw err;
55
+ }
45
56
  try {
46
- this._localStream = await navigator.mediaDevices.getUserMedia({ video, audio });
57
+ // Always request video so both peers negotiate a video track in the SDP.
58
+ // For audio-only calls the video track is immediately disabled (black frame)
59
+ // but stays in the SDP as sendrecv, allowing replaceTrack for screen sharing.
60
+ this._localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio });
47
61
  this._callbacks.onLocalStream(this._localStream);
62
+ if (!video) {
63
+ for (const vt of this._localStream.getVideoTracks()) {
64
+ vt.enabled = false;
65
+ }
66
+ }
48
67
  for (const track of this._localStream.getTracks()) {
49
68
  this._pc?.addTrack(track, this._localStream);
50
69
  }
@@ -129,14 +148,18 @@ export class WebRTCManager {
129
148
  }
130
149
  /** Replace video track with screen share */
131
150
  async startScreenShare() {
132
- const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
151
+ if (!navigator.mediaDevices?.getDisplayMedia) {
152
+ throw new Error('Screen sharing unavailable — HTTPS is required');
153
+ }
154
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
133
155
  const screenTrack = stream.getVideoTracks()[0];
134
- if (this._pc && this._localStream) {
156
+ if (this._pc) {
135
157
  const sender = this._pc.getSenders().find(s => s.track?.kind === 'video');
136
158
  if (sender) {
137
159
  await sender.replaceTrack(screenTrack);
138
160
  }
139
161
  }
162
+ this._screenStream = stream;
140
163
  // When user stops sharing via browser UI
141
164
  screenTrack.onended = () => {
142
165
  this.stopScreenShare();
@@ -145,18 +168,27 @@ export class WebRTCManager {
145
168
  }
146
169
  /** Revert from screen share back to camera */
147
170
  async stopScreenShare() {
148
- if (this._localStream && this._pc) {
149
- const cameraTrack = this._localStream.getVideoTracks()[0];
150
- if (cameraTrack) {
151
- const sender = this._pc.getSenders().find(s => s.track?.kind === 'video');
152
- if (sender) {
153
- await sender.replaceTrack(cameraTrack);
154
- }
155
- }
171
+ // Stop all screen share tracks so the OS stops the sharing indicator
172
+ if (this._screenStream) {
173
+ for (const track of this._screenStream.getTracks())
174
+ track.stop();
175
+ this._screenStream = null;
176
+ }
177
+ if (!this._pc || !this._localStream)
178
+ return;
179
+ const cameraTrack = this._localStream.getVideoTracks()[0] || null;
180
+ const sender = this._pc.getSenders().find(s => s.track?.kind === 'video');
181
+ if (sender) {
182
+ await sender.replaceTrack(cameraTrack);
156
183
  }
157
184
  }
158
185
  /** Clean up everything */
159
186
  destroy() {
187
+ if (this._screenStream) {
188
+ for (const track of this._screenStream.getTracks())
189
+ track.stop();
190
+ this._screenStream = null;
191
+ }
160
192
  if (this._localStream) {
161
193
  for (const track of this._localStream.getTracks()) {
162
194
  track.stop();
@@ -176,3 +208,221 @@ export class WebRTCManager {
176
208
  this._pendingCandidates = [];
177
209
  }
178
210
  }
211
+ // ── Group WebRTC Manager (mesh topology) ──────────────────────────
212
+ const MAX_GROUP_PARTICIPANTS = 8;
213
+ export class GroupWebRTCManager {
214
+ constructor(callbacks, iceServers) {
215
+ this._peers = new Map();
216
+ this._localStream = null;
217
+ this._screenStream = null;
218
+ this._callbacks = callbacks;
219
+ this._iceServers = getIceServers(iceServers);
220
+ }
221
+ get localStream() { return this._localStream; }
222
+ get peerCount() { return this._peers.size; }
223
+ getRemoteStream(userId) {
224
+ return this._peers.get(userId)?.remoteStream ?? null;
225
+ }
226
+ getRemoteStreams() {
227
+ const result = new Map();
228
+ for (const [uid, entry] of this._peers) {
229
+ if (entry.remoteStream)
230
+ result.set(uid, entry.remoteStream);
231
+ }
232
+ return result;
233
+ }
234
+ /** Acquire local media — call once before adding peers */
235
+ async startLocalMedia(video = true, audio = true) {
236
+ if (!navigator.mediaDevices?.getUserMedia) {
237
+ const err = new Error('Media devices unavailable — HTTPS is required for calls');
238
+ this._callbacks.onError(err);
239
+ throw err;
240
+ }
241
+ try {
242
+ this._localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio });
243
+ this._callbacks.onLocalStream(this._localStream);
244
+ if (!video) {
245
+ for (const vt of this._localStream.getVideoTracks())
246
+ vt.enabled = false;
247
+ }
248
+ return this._localStream;
249
+ }
250
+ catch (err) {
251
+ this._callbacks.onError(new Error(`Failed to access media: ${err.message}`));
252
+ throw err;
253
+ }
254
+ }
255
+ /** Create a peer connection for a remote user and optionally create an offer */
256
+ async addPeer(userId, isCaller) {
257
+ if (this._peers.has(userId))
258
+ return;
259
+ if (this._peers.size >= MAX_GROUP_PARTICIPANTS - 1) {
260
+ this._callbacks.onError(new Error(`Group call limit reached (${MAX_GROUP_PARTICIPANTS} participants)`));
261
+ return;
262
+ }
263
+ const pc = new RTCPeerConnection({ iceServers: this._iceServers });
264
+ const entry = { pc, remoteStream: null, pendingCandidates: [] };
265
+ this._peers.set(userId, entry);
266
+ pc.onicecandidate = (event) => {
267
+ if (event.candidate) {
268
+ this._callbacks.onIceCandidate(userId, event.candidate);
269
+ }
270
+ };
271
+ pc.ontrack = (event) => {
272
+ if (!entry.remoteStream) {
273
+ entry.remoteStream = new MediaStream();
274
+ this._callbacks.onRemoteStream(userId, entry.remoteStream);
275
+ }
276
+ entry.remoteStream.addTrack(event.track);
277
+ };
278
+ pc.onconnectionstatechange = () => {
279
+ this._callbacks.onConnectionStateChange(userId, pc.connectionState);
280
+ if (pc.connectionState === 'failed') {
281
+ this.removePeer(userId);
282
+ }
283
+ };
284
+ // Add local tracks
285
+ if (this._localStream) {
286
+ for (const track of this._localStream.getTracks()) {
287
+ pc.addTrack(track, this._localStream);
288
+ }
289
+ }
290
+ if (isCaller) {
291
+ const offer = await pc.createOffer();
292
+ await pc.setLocalDescription(offer);
293
+ return offer.sdp;
294
+ }
295
+ }
296
+ /** Remove and close a peer connection */
297
+ removePeer(userId) {
298
+ const entry = this._peers.get(userId);
299
+ if (!entry)
300
+ return;
301
+ entry.pc.close();
302
+ if (entry.remoteStream) {
303
+ for (const track of entry.remoteStream.getTracks())
304
+ track.stop();
305
+ }
306
+ this._peers.delete(userId);
307
+ this._callbacks.onRemoteStreamRemoved(userId);
308
+ }
309
+ /** Handle an SDP offer from a remote peer and return an answer */
310
+ async handleOffer(fromUserId, sdp) {
311
+ let entry = this._peers.get(fromUserId);
312
+ if (!entry) {
313
+ // Auto-create peer for the offerer
314
+ await this.addPeer(fromUserId, false);
315
+ entry = this._peers.get(fromUserId);
316
+ }
317
+ const { pc } = entry;
318
+ await pc.setRemoteDescription({ type: 'offer', sdp });
319
+ await this._flushPendingCandidates(fromUserId);
320
+ const answer = await pc.createAnswer();
321
+ await pc.setLocalDescription(answer);
322
+ return answer.sdp;
323
+ }
324
+ /** Handle an SDP answer from a remote peer */
325
+ async handleAnswer(fromUserId, sdp) {
326
+ const entry = this._peers.get(fromUserId);
327
+ if (!entry)
328
+ return;
329
+ await entry.pc.setRemoteDescription({ type: 'answer', sdp });
330
+ await this._flushPendingCandidates(fromUserId);
331
+ }
332
+ /** Add an ICE candidate for a specific peer */
333
+ async addIceCandidate(fromUserId, candidate, sdpMLineIndex, sdpMid) {
334
+ const init = {
335
+ candidate,
336
+ sdpMLineIndex: sdpMLineIndex ?? undefined,
337
+ sdpMid: sdpMid ?? undefined,
338
+ };
339
+ const entry = this._peers.get(fromUserId);
340
+ if (!entry)
341
+ return;
342
+ if (!entry.pc.remoteDescription) {
343
+ entry.pendingCandidates.push(init);
344
+ return;
345
+ }
346
+ try {
347
+ await entry.pc.addIceCandidate(init);
348
+ }
349
+ catch (err) {
350
+ console.warn(`[GroupWebRTC] Failed to add ICE candidate for ${fromUserId}:`, err);
351
+ }
352
+ }
353
+ async _flushPendingCandidates(userId) {
354
+ const entry = this._peers.get(userId);
355
+ if (!entry)
356
+ return;
357
+ for (const c of entry.pendingCandidates) {
358
+ try {
359
+ await entry.pc.addIceCandidate(c);
360
+ }
361
+ catch { }
362
+ }
363
+ entry.pendingCandidates = [];
364
+ }
365
+ /** Toggle audio for all peers */
366
+ setAudioEnabled(enabled) {
367
+ if (this._localStream) {
368
+ for (const track of this._localStream.getAudioTracks())
369
+ track.enabled = enabled;
370
+ }
371
+ }
372
+ /** Toggle video for all peers */
373
+ setVideoEnabled(enabled) {
374
+ if (this._localStream) {
375
+ for (const track of this._localStream.getVideoTracks())
376
+ track.enabled = enabled;
377
+ }
378
+ }
379
+ /** Replace camera track with screen share on all peers */
380
+ async startScreenShare() {
381
+ if (!navigator.mediaDevices?.getDisplayMedia) {
382
+ throw new Error('Screen sharing unavailable — HTTPS is required');
383
+ }
384
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
385
+ const screenTrack = stream.getVideoTracks()[0];
386
+ for (const [, entry] of this._peers) {
387
+ const sender = entry.pc.getSenders().find(s => s.track?.kind === 'video');
388
+ if (sender)
389
+ await sender.replaceTrack(screenTrack);
390
+ }
391
+ this._screenStream = stream;
392
+ screenTrack.onended = () => { this.stopScreenShare(); };
393
+ return stream;
394
+ }
395
+ /** Revert from screen share back to camera on all peers */
396
+ async stopScreenShare() {
397
+ if (this._screenStream) {
398
+ for (const track of this._screenStream.getTracks())
399
+ track.stop();
400
+ this._screenStream = null;
401
+ }
402
+ if (!this._localStream)
403
+ return;
404
+ const cameraTrack = this._localStream.getVideoTracks()[0] || null;
405
+ for (const [, entry] of this._peers) {
406
+ const sender = entry.pc.getSenders().find(s => s.track?.kind === 'video');
407
+ if (sender)
408
+ await sender.replaceTrack(cameraTrack);
409
+ }
410
+ }
411
+ /** Clean up everything */
412
+ destroy() {
413
+ if (this._screenStream) {
414
+ for (const track of this._screenStream.getTracks())
415
+ track.stop();
416
+ this._screenStream = null;
417
+ }
418
+ const userIds = [...this._peers.keys()];
419
+ for (const userId of userIds) {
420
+ this.removePeer(userId);
421
+ }
422
+ if (this._localStream) {
423
+ for (const track of this._localStream.getTracks())
424
+ track.stop();
425
+ this._localStream = null;
426
+ }
427
+ }
428
+ }
@@ -1,3 +1,6 @@
1
+ // Use the proper SSR HTMLElement shim so that @lit-labs/ssr's LitElementRenderer.renderAttributes()
2
+ // finds a working `element.attributes` property. A bare `class HTMLElement {}` leaves it undefined.
3
+ import { HTMLElement as SSRHTMLElement } from '@lit-labs/ssr-dom-shim';
1
4
  /**
2
5
  * Install DOM shims needed for SSR rendering of Lit/NuralyUI components.
3
6
  * Consolidates the various partial shim implementations across the codebase.
@@ -6,8 +9,7 @@ export function installDomShims() {
6
9
  const g = globalThis;
7
10
  const noop = () => null;
8
11
  if (!g.HTMLElement) {
9
- g.HTMLElement = class HTMLElement {
10
- };
12
+ g.HTMLElement = SSRHTMLElement;
11
13
  }
12
14
  if (!g.customElements) {
13
15
  const registry = new Map();
@@ -9,6 +9,7 @@ export interface ManifestRoute {
9
9
  module: string;
10
10
  hasLoader: boolean;
11
11
  hasSubscribe: boolean;
12
+ hasSocket?: boolean;
12
13
  hasAuth?: boolean;
13
14
  hasMeta?: boolean;
14
15
  authRoles?: string[];