@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.
- package/dist/auth/native-auth.d.ts +9 -0
- package/dist/auth/native-auth.js +49 -2
- package/dist/auth/routes/login.js +24 -1
- package/dist/auth/routes/totp.d.ts +22 -0
- package/dist/auth/routes/totp.js +232 -0
- package/dist/auth/routes.js +14 -0
- package/dist/auth/token.js +2 -2
- package/dist/build/build-server.d.ts +2 -1
- package/dist/build/build-server.js +10 -1
- package/dist/build/build.js +13 -4
- package/dist/build/scan.d.ts +1 -0
- package/dist/build/scan.js +2 -1
- package/dist/build/serve.js +131 -11
- package/dist/dev-server/config.js +18 -1
- package/dist/dev-server/index-html.d.ts +1 -0
- package/dist/dev-server/index-html.js +4 -1
- package/dist/dev-server/plugins/vite-plugin-routes.js +3 -2
- package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +34 -6
- package/dist/dev-server/server.js +146 -88
- package/dist/dev-server/ssr-render.js +10 -2
- package/dist/editor/ai/backend.js +11 -2
- package/dist/editor/ai/deepseek-client.d.ts +7 -0
- package/dist/editor/ai/deepseek-client.js +113 -0
- package/dist/editor/ai/opencode-client.d.ts +1 -1
- package/dist/editor/ai/opencode-client.js +21 -47
- package/dist/editor/ai-chat-panel.js +27 -1
- package/dist/editor/editor-bridge.js +2 -1
- package/dist/editor/overlay-hmr.js +2 -1
- package/dist/runtime/app-shell.d.ts +1 -1
- package/dist/runtime/app-shell.js +1 -0
- package/dist/runtime/island.d.ts +16 -0
- package/dist/runtime/island.js +80 -0
- package/dist/runtime/router-hydration.js +9 -2
- package/dist/runtime/router.d.ts +3 -1
- package/dist/runtime/router.js +49 -1
- package/dist/runtime/webrtc.d.ts +44 -0
- package/dist/runtime/webrtc.js +263 -13
- package/dist/shared/dom-shims.js +4 -2
- package/dist/shared/types.d.ts +1 -0
- package/dist/storage/adapters/s3.js +6 -3
- package/package.json +33 -7
- package/templates/social/api/posts/[id].ts +0 -14
- package/templates/social/api/posts.ts +0 -11
- package/templates/social/api/profile/[username].ts +0 -10
- package/templates/social/api/upload.ts +0 -19
- package/templates/social/data/migrations/001_init.sql +0 -78
- package/templates/social/data/migrations/002_add_image_url.sql +0 -1
- package/templates/social/data/migrations/003_auth.sql +0 -7
- package/templates/social/docs/architecture.md +0 -76
- package/templates/social/docs/components.md +0 -100
- package/templates/social/docs/data.md +0 -89
- package/templates/social/docs/pages.md +0 -96
- package/templates/social/docs/theming.md +0 -52
- package/templates/social/lib/media.ts +0 -130
- package/templates/social/lumenjs.auth.ts +0 -21
- package/templates/social/lumenjs.config.ts +0 -3
- package/templates/social/package.json +0 -5
- package/templates/social/pages/_layout.ts +0 -239
- package/templates/social/pages/apps/[id].ts +0 -173
- package/templates/social/pages/apps/index.ts +0 -116
- package/templates/social/pages/auth/login.ts +0 -92
- package/templates/social/pages/bookmarks.ts +0 -57
- package/templates/social/pages/explore.ts +0 -73
- package/templates/social/pages/index.ts +0 -351
- package/templates/social/pages/messages.ts +0 -298
- package/templates/social/pages/new.ts +0 -77
- package/templates/social/pages/notifications.ts +0 -73
- package/templates/social/pages/post/[id].ts +0 -124
- package/templates/social/pages/profile/[username].ts +0 -100
- package/templates/social/pages/settings/accessibility.ts +0 -153
- package/templates/social/pages/settings/account.ts +0 -260
- package/templates/social/pages/settings/help.ts +0 -141
- package/templates/social/pages/settings/language.ts +0 -103
- package/templates/social/pages/settings/privacy.ts +0 -183
- package/templates/social/pages/settings/security.ts +0 -133
- 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
|
|
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
|
-
|
|
1
|
+
import './island.js';
|
|
@@ -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
|
|
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
|
-
|
|
35
|
+
if (config) {
|
|
36
|
+
matchPath = stripLocalePrefix(matchPath);
|
|
37
|
+
}
|
|
31
38
|
const match = matchRoute(matchPath);
|
|
32
39
|
if (!match)
|
|
33
40
|
return;
|
package/dist/runtime/router.d.ts
CHANGED
|
@@ -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;
|
package/dist/runtime/router.js
CHANGED
|
@@ -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
|
}
|
package/dist/runtime/webrtc.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/runtime/webrtc.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
}
|
package/dist/shared/dom-shims.js
CHANGED
|
@@ -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 =
|
|
10
|
-
};
|
|
12
|
+
g.HTMLElement = SSRHTMLElement;
|
|
11
13
|
}
|
|
12
14
|
if (!g.customElements) {
|
|
13
15
|
const registry = new Map();
|