@nuraly/lumenjs 0.3.0 → 0.5.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/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  # LumenJS
9
9
 
10
+ > **Mirror**: This repository is a read-only mirror of the original private repository, automatically synced on push to main.
11
+
10
12
  A full-stack web framework for [Lit](https://lit.dev/) web components. File-based routing, server loaders, real-time subscriptions (SSE), SSR with hydration, nested layouts, API routes, i18n, and a visual editor — all powered by Vite.
11
13
 
12
14
  ## Getting Started
@@ -88,6 +90,36 @@ export class BlogPost extends LitElement {
88
90
  }
89
91
  ```
90
92
 
93
+ ### Splitting large loaders
94
+
95
+ For folder routes (`pages/foo/index.ts`), you can move the loader into a co-located `_loader.ts` file. The framework discovers it automatically — no import or wrapper needed in the page file.
96
+
97
+ ```
98
+ pages/
99
+ └── dashboard/
100
+ ├── index.ts ← page component only
101
+ └── _loader.ts ← auto-discovered loader
102
+ ```
103
+
104
+ ```typescript
105
+ // pages/dashboard/_loader.ts
106
+ export async function loader({ user }) {
107
+ const stats = await db.getStats(user.id);
108
+ return { stats };
109
+ }
110
+ ```
111
+
112
+ ```typescript
113
+ // pages/dashboard/index.ts — no loader here at all
114
+ export class PageDashboard extends LitElement {
115
+ static properties = { stats: { type: Array } };
116
+ stats = [];
117
+ render() { ... }
118
+ }
119
+ ```
120
+
121
+ Both patterns work side by side — the inline loader always takes precedence. Only folder routes (`index.ts`) support `_loader.ts` discovery; flat pages (`about.ts`) keep the loader inline.
122
+
91
123
  ### Loader Context
92
124
 
93
125
  | Property | Type | Description |
@@ -9,6 +9,18 @@ export async function buildServer(opts) {
9
9
  // Include all pages in server build (enables SSR for .md endpoints)
10
10
  for (const entry of pageEntries) {
11
11
  serverEntries[`pages/${entry.name}`] = entry.filePath;
12
+ // Co-located _loader.ts for folder index pages
13
+ if (path.basename(entry.filePath).replace(/\.(ts|js)$/, '') === 'index') {
14
+ const dir = path.dirname(entry.filePath);
15
+ for (const ext of ['.ts', '.js']) {
16
+ const loaderFile = path.join(dir, `_loader${ext}`);
17
+ if (fs.existsSync(loaderFile)) {
18
+ const entryDir = path.dirname(entry.name);
19
+ serverEntries[`pages/${entryDir}/_loader`] = loaderFile;
20
+ break;
21
+ }
22
+ }
23
+ }
12
24
  }
13
25
  for (const entry of layoutEntries) {
14
26
  if (entry.hasLoader || entry.hasSubscribe) {
@@ -14,8 +14,11 @@ function analyzePageFile(filePath) {
14
14
  return false;
15
15
  return true;
16
16
  };
17
+ const hasColocatedLoader = path.basename(filePath).replace(/\.(ts|js)$/, '') === 'index' &&
18
+ (fs.existsSync(path.join(path.dirname(filePath), '_loader.ts')) ||
19
+ fs.existsSync(path.join(path.dirname(filePath), '_loader.js')));
17
20
  return {
18
- hasLoader: hasExportBefore(/export\s+(async\s+)?function\s+loader\s*\(/),
21
+ hasLoader: hasExportBefore(/export\s+(async\s+)?function\s+loader\s*\(/) || hasColocatedLoader,
19
22
  hasSubscribe: hasExportBefore(/export\s+(async\s+)?function\s+subscribe\s*\(/),
20
23
  hasSocket: /export\s+(function|const)\s+socket[\s(=]/.test(content),
21
24
  hasAuth: hasExportBefore(/export\s+const\s+auth\s*=/),
@@ -207,14 +207,24 @@ export async function handleLoaderRequest(manifest, serverDir, pagesDir, pathnam
207
207
  }
208
208
  try {
209
209
  const mod = await import(modulePath);
210
- if (!mod.loader || typeof mod.loader !== 'function') {
210
+ let loaderFn = mod.loader && typeof mod.loader === 'function' ? mod.loader : null;
211
+ // Fallback: co-located _loader.js for folder index pages
212
+ if (!loaderFn && path.basename(modulePath, '.js') === 'index') {
213
+ const colocated = path.join(path.dirname(modulePath), '_loader.js');
214
+ if (fs.existsSync(colocated)) {
215
+ const loaderMod = await import(colocated);
216
+ if (loaderMod.loader && typeof loaderMod.loader === 'function')
217
+ loaderFn = loaderMod.loader;
218
+ }
219
+ }
220
+ if (!loaderFn) {
211
221
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
212
222
  res.end(JSON.stringify({ __nk_no_loader: true }));
213
223
  return;
214
224
  }
215
225
  const locale = query.__locale;
216
226
  delete query.__locale;
217
- const result = await mod.loader({ params: matched.params, query, url: pagePath, headers, locale, user: user ?? null });
227
+ const result = await loaderFn({ params: matched.params, query, url: pagePath, headers, locale, user: user ?? null });
218
228
  if (isRedirectResponse(result)) {
219
229
  res.writeHead(result.status || 302, { Location: result.location });
220
230
  res.end();
@@ -18,10 +18,19 @@ export async function handlePageRoute(manifest, serverDir, pagesDir, pathname, q
18
18
  if (fs.existsSync(modulePath)) {
19
19
  try {
20
20
  const mod = await import(modulePath);
21
- // Run loader
21
+ // Run loader (inline or co-located _loader.js)
22
22
  let loaderData = undefined;
23
- if (mod.loader && typeof mod.loader === 'function') {
24
- loaderData = await mod.loader({ params: matched.params, query: {}, url: pathname, headers: req.headers, user: req.nkAuth?.user ?? null });
23
+ let loaderFn = mod.loader && typeof mod.loader === 'function' ? mod.loader : null;
24
+ if (!loaderFn && path.basename(modulePath, '.js') === 'index') {
25
+ const colocated = path.join(path.dirname(modulePath), '_loader.js');
26
+ if (fs.existsSync(colocated)) {
27
+ const loaderMod = await import(colocated);
28
+ if (loaderMod.loader && typeof loaderMod.loader === 'function')
29
+ loaderFn = loaderMod.loader;
30
+ }
31
+ }
32
+ if (loaderFn) {
33
+ loaderData = await loaderFn({ params: matched.params, query: {}, url: pathname, headers: req.headers, user: req.nkAuth?.user ?? null });
25
34
  if (isRedirectResponse(loaderData)) {
26
35
  res.writeHead(loaderData.status || 302, { Location: loaderData.location });
27
36
  res.end();
@@ -70,6 +70,7 @@ export function createCommunicationHandler(options = {}) {
70
70
  }
71
71
  },
72
72
  broadcastAll: ctx.room.broadcastAll,
73
+ db: options.db,
73
74
  };
74
75
  // Broadcast online status if this is the user's first socket
75
76
  if (isFirstSocket) {
@@ -8,6 +8,8 @@ export interface SignalingContext {
8
8
  emitToSocket: (socketId: string, data: any) => void;
9
9
  /** Broadcast to all sockets in a room */
10
10
  broadcastAll: (room: string, data: any) => void;
11
+ /** Optional database for persisting call logs */
12
+ db?: any;
11
13
  }
12
14
  export declare function handleCallInitiate(ctx: SignalingContext, data: {
13
15
  conversationId: string;
@@ -130,6 +130,47 @@ export function handleCallHangup(ctx, data) {
130
130
  data: { callId: data.callId, state: 'ended', endReason: data.reason },
131
131
  });
132
132
  }
133
+ // Persist call log as a message in the conversation
134
+ if (ctx.db && call.conversationId) {
135
+ try {
136
+ const callStatus = data.reason === 'rejected' ? 'declined'
137
+ : data.reason === 'missed' ? 'missed'
138
+ : 'completed';
139
+ const attachment = JSON.stringify({
140
+ callType: call.type || 'audio',
141
+ callStatus,
142
+ duration: data.duration || null,
143
+ });
144
+ const msgId = `call-${data.callId}`;
145
+ const isPg = !!ctx.db.isPg;
146
+ if (isPg) {
147
+ ctx.db.run(`INSERT INTO messages (id, conversation_id, sender_id, content, type, attachment, created_at)
148
+ VALUES ($1, $2, $3, $4, $5, $6, NOW()) ON CONFLICT (id) DO NOTHING`, msgId, call.conversationId, call.callerId, '', 'call', attachment);
149
+ ctx.db.run(`UPDATE conversations SET updated_at = NOW() WHERE id = $1`, call.conversationId);
150
+ }
151
+ else {
152
+ ctx.db.run(`INSERT OR IGNORE INTO messages (id, conversation_id, sender_id, content, type, attachment, created_at)
153
+ VALUES (?, ?, ?, '', 'call', ?, datetime('now'))`, msgId, call.conversationId, call.callerId, attachment);
154
+ ctx.db.run(`UPDATE conversations SET updated_at = datetime('now') WHERE id = ?`, call.conversationId);
155
+ }
156
+ // Broadcast call message to all participants
157
+ const callMsg = {
158
+ id: msgId,
159
+ conversationId: call.conversationId,
160
+ senderId: call.callerId,
161
+ content: '',
162
+ type: 'call',
163
+ attachment: { callType: call.type || 'audio', callStatus, duration: data.duration || null },
164
+ createdAt: new Date().toISOString(),
165
+ };
166
+ for (const uid of allUsers) {
167
+ emitToUser(ctx, uid, { event: 'message:new', data: callMsg });
168
+ }
169
+ // Also emit to the caller
170
+ emitToUser(ctx, ctx.userId, { event: 'message:new', data: callMsg });
171
+ }
172
+ catch { }
173
+ }
133
174
  ctx.store.removeCall(data.callId);
134
175
  }
135
176
  }
@@ -159,7 +159,21 @@ export function lumenLoadersPlugin(pagesDir) {
159
159
  // Provide minimal DOM shims for SSR so Lit class definitions don't crash
160
160
  installDomShims();
161
161
  const mod = await server.ssrLoadModule(filePath);
162
- if (!mod.loader || typeof mod.loader !== 'function') {
162
+ let loaderFn = mod.loader && typeof mod.loader === 'function' ? mod.loader : null;
163
+ // Fallback: co-located _loader.ts for folder index pages
164
+ if (!loaderFn && path.basename(filePath).replace(/\.(ts|js)$/, '') === 'index') {
165
+ for (const ext of ['.ts', '.js']) {
166
+ const colocated = path.join(path.dirname(filePath), `_loader${ext}`);
167
+ if (fs.existsSync(colocated)) {
168
+ const loaderMod = await server.ssrLoadModule(colocated);
169
+ if (loaderMod.loader && typeof loaderMod.loader === 'function') {
170
+ loaderFn = loaderMod.loader;
171
+ }
172
+ break;
173
+ }
174
+ }
175
+ }
176
+ if (!loaderFn) {
163
177
  // No loader — return empty data
164
178
  res.statusCode = 200;
165
179
  res.setHeader('Content-Type', 'application/json');
@@ -205,7 +219,7 @@ export function lumenLoadersPlugin(pagesDir) {
205
219
  }
206
220
  catch { }
207
221
  }
208
- const result = await mod.loader({ params, query, url: pagePath, headers: req.headers, locale, user });
222
+ const result = await loaderFn({ params, query, url: pagePath, headers: req.headers, locale, user });
209
223
  if (isRedirectResponse(result)) {
210
224
  res.statusCode = result.status || 302;
211
225
  res.setHeader('Location', result.location);
@@ -44,10 +44,22 @@ export async function ssrRenderPage(server, pagesDir, pathname, headers, locale,
44
44
  const mod = await server.ssrLoadModule(pageModuleUrl);
45
45
  if (registry)
46
46
  registry.__nk_bypass_get = false;
47
- // Run loader if present
47
+ // Run loader if present (inline or co-located _loader.ts)
48
48
  let loaderData = undefined;
49
- if (mod.loader && typeof mod.loader === 'function') {
50
- loaderData = await mod.loader({ params, query: {}, url: pathname, headers: headers || {}, locale, user: user ?? null });
49
+ let loaderFn = mod.loader && typeof mod.loader === 'function' ? mod.loader : null;
50
+ if (!loaderFn && path.basename(filePath).replace(/\.(ts|js)$/, '') === 'index') {
51
+ for (const ext of ['.ts', '.js']) {
52
+ const colocated = path.join(path.dirname(filePath), `_loader${ext}`);
53
+ if (fs.existsSync(colocated)) {
54
+ const loaderMod = await server.ssrLoadModule('/' + path.relative(path.resolve(pagesDir, '..'), colocated).replace(/\\/g, '/'));
55
+ if (loaderMod.loader && typeof loaderMod.loader === 'function')
56
+ loaderFn = loaderMod.loader;
57
+ break;
58
+ }
59
+ }
60
+ }
61
+ if (loaderFn) {
62
+ loaderData = await loaderFn({ params, query: {}, url: pathname, headers: headers || {}, locale, user: user ?? null });
51
63
  if (loaderData && typeof loaderData === 'object' && loaderData.__nk_redirect) {
52
64
  return { html: '', loaderData: null, redirect: { location: loaderData.location, status: loaderData.status || 302 } };
53
65
  }
@@ -66,7 +66,14 @@ export declare class GroupWebRTCManager {
66
66
  getRemoteStream(userId: string): MediaStream | null;
67
67
  getRemoteStreams(): Map<string, MediaStream>;
68
68
  /** Acquire local media — call once before adding peers */
69
- startLocalMedia(video?: boolean, audio?: boolean): Promise<MediaStream>;
69
+ startLocalMedia(video?: boolean, audio?: boolean, deviceIds?: {
70
+ audioInputId?: string | null;
71
+ videoInputId?: string | null;
72
+ }): Promise<MediaStream>;
73
+ /** Replace the audio track on all peer connections (for switching mic mid-call) */
74
+ replaceAudioTrack(newTrack: MediaStreamTrack): Promise<void>;
75
+ /** Replace the video track on all peer connections (for switching camera mid-call) */
76
+ replaceVideoTrack(newTrack: MediaStreamTrack): Promise<void>;
70
77
  /** Create a peer connection for a remote user and optionally create an offer */
71
78
  addPeer(userId: string, isCaller: boolean): Promise<string | void>;
72
79
  /** Remove and close a peer connection */
@@ -54,16 +54,11 @@ export class WebRTCManager {
54
54
  throw err;
55
55
  }
56
56
  try {
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 });
57
+ // Only request video when the call type is video.
58
+ // For audio-only calls, skip the camera entirely to avoid the permission
59
+ // prompt and camera LED. Screen sharing can add a video track later.
60
+ this._localStream = await navigator.mediaDevices.getUserMedia({ video, audio });
61
61
  this._callbacks.onLocalStream(this._localStream);
62
- if (!video) {
63
- for (const vt of this._localStream.getVideoTracks()) {
64
- vt.enabled = false;
65
- }
66
- }
67
62
  for (const track of this._localStream.getTracks()) {
68
63
  this._pc?.addTrack(track, this._localStream);
69
64
  }
@@ -232,19 +227,24 @@ export class GroupWebRTCManager {
232
227
  return result;
233
228
  }
234
229
  /** Acquire local media — call once before adding peers */
235
- async startLocalMedia(video = true, audio = true) {
230
+ async startLocalMedia(video = true, audio = true, deviceIds) {
236
231
  if (!navigator.mediaDevices?.getUserMedia) {
237
232
  const err = new Error('Media devices unavailable — HTTPS is required for calls');
238
233
  this._callbacks.onError(err);
239
234
  throw err;
240
235
  }
241
236
  try {
242
- this._localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio });
237
+ const audioConstraint = audio
238
+ ? (deviceIds?.audioInputId ? { deviceId: { exact: deviceIds.audioInputId } } : true)
239
+ : false;
240
+ const videoConstraint = video
241
+ ? (deviceIds?.videoInputId ? { deviceId: { exact: deviceIds.videoInputId } } : true)
242
+ : false;
243
+ this._localStream = await navigator.mediaDevices.getUserMedia({
244
+ audio: audioConstraint,
245
+ video: videoConstraint,
246
+ });
243
247
  this._callbacks.onLocalStream(this._localStream);
244
- if (!video) {
245
- for (const vt of this._localStream.getVideoTracks())
246
- vt.enabled = false;
247
- }
248
248
  return this._localStream;
249
249
  }
250
250
  catch (err) {
@@ -252,6 +252,40 @@ export class GroupWebRTCManager {
252
252
  throw err;
253
253
  }
254
254
  }
255
+ /** Replace the audio track on all peer connections (for switching mic mid-call) */
256
+ async replaceAudioTrack(newTrack) {
257
+ // Replace in local stream
258
+ if (this._localStream) {
259
+ const old = this._localStream.getAudioTracks()[0];
260
+ if (old) {
261
+ this._localStream.removeTrack(old);
262
+ old.stop();
263
+ }
264
+ this._localStream.addTrack(newTrack);
265
+ }
266
+ // Replace on all peer connections
267
+ for (const [, entry] of this._peers) {
268
+ const sender = entry.pc.getSenders().find(s => s.track?.kind === 'audio');
269
+ if (sender)
270
+ await sender.replaceTrack(newTrack);
271
+ }
272
+ }
273
+ /** Replace the video track on all peer connections (for switching camera mid-call) */
274
+ async replaceVideoTrack(newTrack) {
275
+ if (this._localStream) {
276
+ const old = this._localStream.getVideoTracks()[0];
277
+ if (old) {
278
+ this._localStream.removeTrack(old);
279
+ old.stop();
280
+ }
281
+ this._localStream.addTrack(newTrack);
282
+ }
283
+ for (const [, entry] of this._peers) {
284
+ const sender = entry.pc.getSenders().find(s => s.track?.kind === 'video');
285
+ if (sender)
286
+ await sender.replaceTrack(newTrack);
287
+ }
288
+ }
255
289
  /** Create a peer connection for a remote user and optionally create an offer */
256
290
  async addPeer(userId, isCaller) {
257
291
  if (this._peers.has(userId))
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs';
2
+ import path from 'path';
2
3
  /**
3
4
  * Strip the outer Lit SSR template markers from rendered HTML.
4
5
  * Lit SSR wraps every template render in <!--lit-part HASH-->...<!--/lit-part-->.
@@ -158,6 +159,13 @@ export function fileHasPrerender(filePath) {
158
159
  */
159
160
  export function fileHasLoader(filePath) {
160
161
  try {
162
+ // Check for co-located _loader.ts (folder route convention: index.ts + _loader.ts)
163
+ if (path.basename(filePath).replace(/\.(ts|js)$/, '') === 'index') {
164
+ const dir = path.dirname(filePath);
165
+ if (fs.existsSync(path.join(dir, '_loader.ts')) || fs.existsSync(path.join(dir, '_loader.js'))) {
166
+ return true;
167
+ }
168
+ }
161
169
  const content = fs.readFileSync(filePath, 'utf-8');
162
170
  return hasTopLevelExport(content, 'loader');
163
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuraly/lumenjs",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -43,7 +43,7 @@
43
43
  "license": "MIT",
44
44
  "dependencies": {
45
45
  "@lit-labs/ssr": "^3.2.0",
46
- "glob": "^10.3.0",
46
+ "glob": "^10.2.1",
47
47
  "lit": "^3.1.0",
48
48
  "vite": "^5.4.0"
49
49
  },