@nuraly/lumenjs 0.1.2 → 0.1.3
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 +70 -9
- package/dist/build/build.js +8 -4
- package/dist/build/scan.d.ts +2 -0
- package/dist/build/scan.js +4 -3
- package/dist/build/serve-loaders.d.ts +2 -0
- package/dist/build/serve-loaders.js +110 -0
- package/dist/build/serve.js +15 -3
- package/dist/db/context.d.ts +2 -0
- package/dist/db/context.js +9 -0
- package/dist/db/index.d.ts +19 -0
- package/dist/db/index.js +79 -0
- package/dist/dev-server/config.d.ts +3 -0
- package/dist/dev-server/config.js +15 -1
- package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +0 -1
- package/dist/dev-server/plugins/vite-plugin-loaders.js +173 -37
- package/dist/dev-server/plugins/vite-plugin-routes.js +5 -3
- package/dist/dev-server/server.js +2 -0
- package/dist/runtime/router-data.d.ts +2 -0
- package/dist/runtime/router-data.js +20 -0
- package/dist/runtime/router.d.ts +5 -0
- package/dist/runtime/router.js +37 -1
- package/dist/shared/types.d.ts +2 -0
- package/dist/shared/utils.d.ts +5 -1
- package/dist/shared/utils.js +13 -1
- package/package.json +8 -1
- package/templates/blog/api/posts.ts +20 -0
- package/templates/blog/data/migrations/001_init.sql +12 -0
- package/templates/blog/pages/index.ts +39 -0
- package/templates/blog/pages/posts/[slug].ts +35 -0
- package/templates/dashboard/api/stats.ts +7 -0
- package/templates/dashboard/data/migrations/001_init.sql +13 -0
- package/templates/dashboard/pages/index.ts +41 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# LumenJS
|
|
2
2
|
|
|
3
|
-
A full-stack web framework for [Lit](https://lit.dev/) web components. File-based routing, server loaders, SSR with hydration, nested layouts, API routes, and a Vite-powered dev server.
|
|
3
|
+
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, and a Vite-powered dev server.
|
|
4
4
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
@@ -49,9 +49,7 @@ Pages are Lit components in the `pages/` directory. The file path determines the
|
|
|
49
49
|
```typescript
|
|
50
50
|
// pages/index.ts
|
|
51
51
|
import { LitElement, html, css } from 'lit';
|
|
52
|
-
import { customElement } from 'lit/decorators.js';
|
|
53
52
|
|
|
54
|
-
@customElement('page-index')
|
|
55
53
|
export class PageIndex extends LitElement {
|
|
56
54
|
static styles = css`:host { display: block; }`;
|
|
57
55
|
|
|
@@ -61,6 +59,8 @@ export class PageIndex extends LitElement {
|
|
|
61
59
|
}
|
|
62
60
|
```
|
|
63
61
|
|
|
62
|
+
The custom element tag name is derived automatically from the file path — no `@customElement` decorator needed.
|
|
63
|
+
|
|
64
64
|
### Routing
|
|
65
65
|
|
|
66
66
|
| File | URL | Tag |
|
|
@@ -85,9 +85,9 @@ export async function loader({ params, headers, query, url }) {
|
|
|
85
85
|
return { post };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
@customElement('page-blog-slug')
|
|
89
88
|
export class BlogPost extends LitElement {
|
|
90
|
-
|
|
89
|
+
static properties = { loaderData: { type: Object } };
|
|
90
|
+
loaderData: any = {};
|
|
91
91
|
|
|
92
92
|
render() {
|
|
93
93
|
return html`<h1>${this.loaderData.post?.title}</h1>`;
|
|
@@ -117,13 +117,75 @@ export async function loader({ headers }) {
|
|
|
117
117
|
}
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
## Live Data (subscribe)
|
|
121
|
+
|
|
122
|
+
Export a `subscribe()` function from any page or layout to push real-time data to the client over Server-Sent Events (SSE).
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
// pages/dashboard.ts
|
|
126
|
+
export async function loader() {
|
|
127
|
+
return { orders: await db.orders.findAll() };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function subscribe({ push }) {
|
|
131
|
+
const stream = db.orders.watch();
|
|
132
|
+
stream.on('change', (change) => push({ type: 'order-update', data: change }));
|
|
133
|
+
return () => stream.close();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class PageDashboard extends LitElement {
|
|
137
|
+
static properties = { loaderData: { type: Object }, liveData: { type: Object } };
|
|
138
|
+
loaderData: any = {};
|
|
139
|
+
liveData: any = null;
|
|
140
|
+
|
|
141
|
+
render() {
|
|
142
|
+
return html`
|
|
143
|
+
<h1>Orders (${this.loaderData.orders?.length})</h1>
|
|
144
|
+
${this.liveData ? html`<p>Update: ${this.liveData.type}</p>` : ''}
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The `subscribe()` function is a persistent server-side process tied to the page lifecycle:
|
|
151
|
+
|
|
152
|
+
1. User opens page → framework opens SSE connection to `/__nk_subscribe/<path>`
|
|
153
|
+
2. Server calls `subscribe()` — function keeps running (DB watchers, intervals, etc.)
|
|
154
|
+
3. Call `push(data)` whenever you want → delivered to client → updates `liveData` property
|
|
155
|
+
4. User navigates away → connection closes → cleanup function runs
|
|
156
|
+
|
|
157
|
+
Like `loader()`, `subscribe()` is stripped from client bundles automatically.
|
|
158
|
+
|
|
159
|
+
### Subscribe Context
|
|
160
|
+
|
|
161
|
+
| Property | Type | Description |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `params` | `Record<string, string>` | Dynamic route parameters |
|
|
164
|
+
| `headers` | `Record<string, any>` | Request headers |
|
|
165
|
+
| `locale` | `string` | Current locale (when i18n is configured) |
|
|
166
|
+
| `push` | `(data: any) => void` | Send SSE event to client (JSON-serialized) |
|
|
167
|
+
|
|
168
|
+
Return a cleanup function that is called when the client disconnects.
|
|
169
|
+
|
|
170
|
+
### Layout Subscribe
|
|
171
|
+
|
|
172
|
+
Layouts can also export `subscribe()` for global live data (notifications, presence, etc.):
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// pages/_layout.ts
|
|
176
|
+
export function subscribe({ push }) {
|
|
177
|
+
const ws = new WebSocket('wss://notifications.example.com');
|
|
178
|
+
ws.on('message', (msg) => push(JSON.parse(msg)));
|
|
179
|
+
return () => ws.close();
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
120
183
|
## Nested Layouts
|
|
121
184
|
|
|
122
185
|
Create `_layout.ts` in any directory to wrap all pages in that directory and its subdirectories.
|
|
123
186
|
|
|
124
187
|
```typescript
|
|
125
188
|
// pages/_layout.ts
|
|
126
|
-
@customElement('layout-root')
|
|
127
189
|
export class RootLayout extends LitElement {
|
|
128
190
|
render() {
|
|
129
191
|
return html`
|
|
@@ -147,9 +209,9 @@ export async function loader({ headers }) {
|
|
|
147
209
|
return { user };
|
|
148
210
|
}
|
|
149
211
|
|
|
150
|
-
@customElement('layout-dashboard')
|
|
151
212
|
export class DashboardLayout extends LitElement {
|
|
152
|
-
|
|
213
|
+
static properties = { loaderData: { type: Object } };
|
|
214
|
+
loaderData: any = {};
|
|
153
215
|
|
|
154
216
|
render() {
|
|
155
217
|
return html`
|
|
@@ -259,7 +321,6 @@ my-app/
|
|
|
259
321
|
```typescript
|
|
260
322
|
import { t, getLocale, setLocale } from '@lumenjs/i18n';
|
|
261
323
|
|
|
262
|
-
@customElement('page-index')
|
|
263
324
|
export class PageIndex extends LitElement {
|
|
264
325
|
render() {
|
|
265
326
|
return html`<h1>${t('home.title')}</h1>`;
|
package/dist/build/build.js
CHANGED
|
@@ -65,12 +65,12 @@ export async function buildProject(options) {
|
|
|
65
65
|
// Collect server entry points (pages with loaders + layouts with loaders + API routes)
|
|
66
66
|
const serverEntries = {};
|
|
67
67
|
for (const entry of pageEntries) {
|
|
68
|
-
if (entry.hasLoader) {
|
|
68
|
+
if (entry.hasLoader || entry.hasSubscribe) {
|
|
69
69
|
serverEntries[`pages/${entry.name}`] = entry.filePath;
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
for (const entry of layoutEntries) {
|
|
73
|
-
if (entry.hasLoader) {
|
|
73
|
+
if (entry.hasLoader || entry.hasSubscribe) {
|
|
74
74
|
const entryName = entry.dir ? `layouts/${entry.dir}/_layout` : 'layouts/_layout';
|
|
75
75
|
serverEntries[entryName] = entry.filePath;
|
|
76
76
|
}
|
|
@@ -124,6 +124,7 @@ export async function buildProject(options) {
|
|
|
124
124
|
'os', 'fs', 'path', 'url', 'util', 'crypto', 'http', 'https', 'net',
|
|
125
125
|
'stream', 'zlib', 'events', 'buffer', 'querystring', 'child_process',
|
|
126
126
|
'worker_threads', 'cluster', 'dns', 'tls', 'assert', 'constants',
|
|
127
|
+
'better-sqlite3',
|
|
127
128
|
],
|
|
128
129
|
},
|
|
129
130
|
},
|
|
@@ -163,8 +164,9 @@ export async function buildProject(options) {
|
|
|
163
164
|
const relPath = path.relative(pagesDir, e.filePath).replace(/\\/g, '/');
|
|
164
165
|
return {
|
|
165
166
|
path: e.routePath,
|
|
166
|
-
module: e.hasLoader ? `pages/${e.name}.js` : '',
|
|
167
|
+
module: (e.hasLoader || e.hasSubscribe) ? `pages/${e.name}.js` : '',
|
|
167
168
|
hasLoader: e.hasLoader,
|
|
169
|
+
hasSubscribe: e.hasSubscribe,
|
|
168
170
|
tagName: filePathToTagName(relPath),
|
|
169
171
|
...(routeLayouts.length > 0 ? { layouts: routeLayouts } : {}),
|
|
170
172
|
};
|
|
@@ -173,11 +175,13 @@ export async function buildProject(options) {
|
|
|
173
175
|
path: `/api/${e.routePath}`,
|
|
174
176
|
module: `api/${e.name}.js`,
|
|
175
177
|
hasLoader: false,
|
|
178
|
+
hasSubscribe: false,
|
|
176
179
|
})),
|
|
177
180
|
layouts: layoutEntries.map(e => ({
|
|
178
181
|
dir: e.dir,
|
|
179
|
-
module: e.hasLoader ? (e.dir ? `layouts/${e.dir}/_layout.js` : 'layouts/_layout.js') : '',
|
|
182
|
+
module: (e.hasLoader || e.hasSubscribe) ? (e.dir ? `layouts/${e.dir}/_layout.js` : 'layouts/_layout.js') : '',
|
|
180
183
|
hasLoader: e.hasLoader,
|
|
184
|
+
hasSubscribe: e.hasSubscribe,
|
|
181
185
|
})),
|
|
182
186
|
...(i18nConfig ? { i18n: i18nConfig } : {}),
|
|
183
187
|
};
|
package/dist/build/scan.d.ts
CHANGED
|
@@ -3,11 +3,13 @@ export interface PageEntry {
|
|
|
3
3
|
filePath: string;
|
|
4
4
|
routePath: string;
|
|
5
5
|
hasLoader: boolean;
|
|
6
|
+
hasSubscribe: boolean;
|
|
6
7
|
}
|
|
7
8
|
export interface LayoutEntry {
|
|
8
9
|
dir: string;
|
|
9
10
|
filePath: string;
|
|
10
11
|
hasLoader: boolean;
|
|
12
|
+
hasSubscribe: boolean;
|
|
11
13
|
}
|
|
12
14
|
export interface ApiEntry {
|
|
13
15
|
name: string;
|
package/dist/build/scan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { fileHasLoader, filePathToRoute } from '../shared/utils.js';
|
|
3
|
+
import { fileHasLoader, fileHasSubscribe, filePathToRoute } from '../shared/utils.js';
|
|
4
4
|
export function scanPages(pagesDir) {
|
|
5
5
|
if (!fs.existsSync(pagesDir))
|
|
6
6
|
return [];
|
|
@@ -54,7 +54,8 @@ function walkDir(baseDir, relativePath, entries, pagesDir) {
|
|
|
54
54
|
const name = entryRelative.replace(/\.(ts|js)$/, '').replace(/\\/g, '/');
|
|
55
55
|
const routePath = filePathToRoute(entryRelative);
|
|
56
56
|
const hasLoader = fileHasLoader(filePath);
|
|
57
|
-
|
|
57
|
+
const hasSubscribe = fileHasSubscribe(filePath);
|
|
58
|
+
entries.push({ name, filePath, routePath, hasLoader, hasSubscribe });
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
}
|
|
@@ -65,7 +66,7 @@ function walkForLayouts(baseDir, relativePath, entries) {
|
|
|
65
66
|
if (entry.isFile() && /^_layout\.(ts|js)$/.test(entry.name)) {
|
|
66
67
|
const filePath = path.join(fullDir, entry.name);
|
|
67
68
|
const dir = relativePath.replace(/\\/g, '/');
|
|
68
|
-
entries.push({ dir, filePath, hasLoader: fileHasLoader(filePath) });
|
|
69
|
+
entries.push({ dir, filePath, hasLoader: fileHasLoader(filePath), hasSubscribe: fileHasSubscribe(filePath) });
|
|
69
70
|
}
|
|
70
71
|
if (entry.isDirectory()) {
|
|
71
72
|
walkForLayouts(baseDir, path.join(relativePath, entry.name), entries);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
2
|
import type { BuildManifest } from '../shared/types.js';
|
|
3
3
|
export declare function handleLayoutLoaderRequest(manifest: BuildManifest, serverDir: string, queryString: string | undefined, headers: http.IncomingHttpHeaders, res: http.ServerResponse): Promise<void>;
|
|
4
|
+
export declare function handleLayoutSubscribeRequest(manifest: BuildManifest, serverDir: string, queryString: string | undefined, headers: http.IncomingHttpHeaders, res: http.ServerResponse): Promise<void>;
|
|
5
|
+
export declare function handleSubscribeRequest(manifest: BuildManifest, serverDir: string, pagesDir: string, pathname: string, queryString: string | undefined, headers: http.IncomingHttpHeaders, res: http.ServerResponse): Promise<void>;
|
|
4
6
|
export declare function handleLoaderRequest(manifest: BuildManifest, serverDir: string, pagesDir: string, pathname: string, queryString: string | undefined, headers: http.IncomingHttpHeaders, res: http.ServerResponse): Promise<void>;
|
|
@@ -56,6 +56,116 @@ export async function handleLayoutLoaderRequest(manifest, serverDir, queryString
|
|
|
56
56
|
res.end(JSON.stringify({ error: message }));
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
export async function handleLayoutSubscribeRequest(manifest, serverDir, queryString, headers, res) {
|
|
60
|
+
const query = {};
|
|
61
|
+
if (queryString) {
|
|
62
|
+
for (const pair of queryString.split('&')) {
|
|
63
|
+
const [key, val] = pair.split('=');
|
|
64
|
+
query[decodeURIComponent(key)] = decodeURIComponent(val || '');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const dir = query.__dir || '';
|
|
68
|
+
const layout = (manifest.layouts || []).find(l => l.dir === dir);
|
|
69
|
+
if (!layout || !layout.hasSubscribe || !layout.module) {
|
|
70
|
+
res.writeHead(204);
|
|
71
|
+
res.end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const modulePath = path.join(serverDir, layout.module);
|
|
75
|
+
if (!fs.existsSync(modulePath)) {
|
|
76
|
+
res.writeHead(204);
|
|
77
|
+
res.end();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const mod = await import(modulePath);
|
|
82
|
+
if (!mod.subscribe || typeof mod.subscribe !== 'function') {
|
|
83
|
+
res.writeHead(204);
|
|
84
|
+
res.end();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
res.writeHead(200, {
|
|
88
|
+
'Content-Type': 'text/event-stream',
|
|
89
|
+
'Cache-Control': 'no-cache',
|
|
90
|
+
'Connection': 'keep-alive',
|
|
91
|
+
});
|
|
92
|
+
const locale = query.__locale;
|
|
93
|
+
const push = (data) => {
|
|
94
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
95
|
+
};
|
|
96
|
+
const cleanup = mod.subscribe({ params: {}, push, headers, locale });
|
|
97
|
+
res.on('close', () => {
|
|
98
|
+
if (typeof cleanup === 'function')
|
|
99
|
+
cleanup();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
console.error(`[LumenJS] Layout subscribe error for dir=${dir}:`, err);
|
|
104
|
+
if (!res.headersSent) {
|
|
105
|
+
res.writeHead(500);
|
|
106
|
+
res.end();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export async function handleSubscribeRequest(manifest, serverDir, pagesDir, pathname, queryString, headers, res) {
|
|
111
|
+
const pagePath = pathname.replace('/__nk_subscribe', '') || '/';
|
|
112
|
+
const query = {};
|
|
113
|
+
if (queryString) {
|
|
114
|
+
for (const pair of queryString.split('&')) {
|
|
115
|
+
const [key, val] = pair.split('=');
|
|
116
|
+
query[decodeURIComponent(key)] = decodeURIComponent(val || '');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let params = {};
|
|
120
|
+
if (query.__params) {
|
|
121
|
+
try {
|
|
122
|
+
params = JSON.parse(query.__params);
|
|
123
|
+
}
|
|
124
|
+
catch { /* ignore */ }
|
|
125
|
+
delete query.__params;
|
|
126
|
+
}
|
|
127
|
+
const matched = matchRoute(manifest.routes.filter(r => r.hasSubscribe), pagePath);
|
|
128
|
+
if (!matched || !matched.route.module) {
|
|
129
|
+
res.writeHead(204);
|
|
130
|
+
res.end();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const modulePath = path.join(serverDir, matched.route.module);
|
|
134
|
+
if (!fs.existsSync(modulePath)) {
|
|
135
|
+
res.writeHead(204);
|
|
136
|
+
res.end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const mod = await import(modulePath);
|
|
141
|
+
if (!mod.subscribe || typeof mod.subscribe !== 'function') {
|
|
142
|
+
res.writeHead(204);
|
|
143
|
+
res.end();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
res.writeHead(200, {
|
|
147
|
+
'Content-Type': 'text/event-stream',
|
|
148
|
+
'Cache-Control': 'no-cache',
|
|
149
|
+
'Connection': 'keep-alive',
|
|
150
|
+
});
|
|
151
|
+
const locale = query.__locale;
|
|
152
|
+
const push = (data) => {
|
|
153
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
154
|
+
};
|
|
155
|
+
const cleanup = mod.subscribe({ params: matched.params, push, headers, locale });
|
|
156
|
+
res.on('close', () => {
|
|
157
|
+
if (typeof cleanup === 'function')
|
|
158
|
+
cleanup();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
console.error(`[LumenJS] Subscribe error for ${pagePath}:`, err);
|
|
163
|
+
if (!res.headersSent) {
|
|
164
|
+
res.writeHead(500);
|
|
165
|
+
res.end();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
59
169
|
export async function handleLoaderRequest(manifest, serverDir, pagesDir, pathname, queryString, headers, res) {
|
|
60
170
|
const pagePath = pathname.replace('/__nk_loader', '') || '/';
|
|
61
171
|
// Parse query params
|
package/dist/build/serve.js
CHANGED
|
@@ -5,13 +5,15 @@ import { readProjectConfig } from '../dev-server/config.js';
|
|
|
5
5
|
import { installDomShims } from '../shared/dom-shims.js';
|
|
6
6
|
import { serveStaticFile, sendCompressed } from './serve-static.js';
|
|
7
7
|
import { handleApiRoute } from './serve-api.js';
|
|
8
|
-
import { handleLoaderRequest, handleLayoutLoaderRequest } from './serve-loaders.js';
|
|
8
|
+
import { handleLoaderRequest, handleLayoutLoaderRequest, handleSubscribeRequest, handleLayoutSubscribeRequest } from './serve-loaders.js';
|
|
9
9
|
import { handlePageRoute } from './serve-ssr.js';
|
|
10
10
|
import { renderErrorPage } from './error-page.js';
|
|
11
11
|
import { handleI18nRequest } from './serve-i18n.js';
|
|
12
12
|
import { resolveLocale } from '../dev-server/middleware/locale.js';
|
|
13
|
+
import { setProjectDir } from '../db/context.js';
|
|
13
14
|
export async function serveProject(options) {
|
|
14
15
|
const { projectDir } = options;
|
|
16
|
+
setProjectDir(projectDir);
|
|
15
17
|
const port = options.port || 3000;
|
|
16
18
|
const outDir = path.join(projectDir, '.lumenjs');
|
|
17
19
|
const clientDir = path.join(outDir, 'client');
|
|
@@ -62,12 +64,22 @@ export async function serveProject(options) {
|
|
|
62
64
|
handleI18nRequest(localesDir, manifest.i18n.locales, pathname, req, res);
|
|
63
65
|
return;
|
|
64
66
|
}
|
|
65
|
-
// 4. Layout
|
|
67
|
+
// 4. Layout subscribe endpoint (SSE)
|
|
68
|
+
if (pathname === '/__nk_subscribe/__layout/' || pathname === '/__nk_subscribe/__layout') {
|
|
69
|
+
await handleLayoutSubscribeRequest(manifest, serverDir, queryString, req.headers, res);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// 5. Subscribe endpoint (SSE)
|
|
73
|
+
if (pathname.startsWith('/__nk_subscribe/')) {
|
|
74
|
+
await handleSubscribeRequest(manifest, serverDir, pagesDir, pathname, queryString, req.headers, res);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// 6. Layout loader endpoint
|
|
66
78
|
if (pathname === '/__nk_loader/__layout/' || pathname === '/__nk_loader/__layout') {
|
|
67
79
|
await handleLayoutLoaderRequest(manifest, serverDir, queryString, req.headers, res);
|
|
68
80
|
return;
|
|
69
81
|
}
|
|
70
|
-
//
|
|
82
|
+
// 7. Loader endpoint for client-side navigation
|
|
71
83
|
if (pathname.startsWith('/__nk_loader/')) {
|
|
72
84
|
await handleLoaderRequest(manifest, serverDir, pagesDir, pathname, queryString, req.headers, res);
|
|
73
85
|
return;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
export declare class LumenDb {
|
|
3
|
+
private db;
|
|
4
|
+
constructor(db: Database.Database);
|
|
5
|
+
/** SELECT multiple rows */
|
|
6
|
+
all<T = any>(sql: string, ...params: any[]): T[];
|
|
7
|
+
/** SELECT single row */
|
|
8
|
+
get<T = any>(sql: string, ...params: any[]): T | undefined;
|
|
9
|
+
/** INSERT/UPDATE/DELETE */
|
|
10
|
+
run(sql: string, ...params: any[]): {
|
|
11
|
+
changes: number;
|
|
12
|
+
lastInsertRowid: number | bigint;
|
|
13
|
+
};
|
|
14
|
+
/** Multi-statement DDL */
|
|
15
|
+
exec(sql: string): void;
|
|
16
|
+
/** Access the underlying better-sqlite3 instance */
|
|
17
|
+
get raw(): Database.Database;
|
|
18
|
+
}
|
|
19
|
+
export declare function useDb(): LumenDb;
|
package/dist/db/index.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
4
|
+
import { getProjectDir } from './context.js';
|
|
5
|
+
import { readProjectConfig } from '../dev-server/config.js';
|
|
6
|
+
export class LumenDb {
|
|
7
|
+
constructor(db) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
}
|
|
10
|
+
/** SELECT multiple rows */
|
|
11
|
+
all(sql, ...params) {
|
|
12
|
+
return this.db.prepare(sql).all(...params);
|
|
13
|
+
}
|
|
14
|
+
/** SELECT single row */
|
|
15
|
+
get(sql, ...params) {
|
|
16
|
+
return this.db.prepare(sql).get(...params);
|
|
17
|
+
}
|
|
18
|
+
/** INSERT/UPDATE/DELETE */
|
|
19
|
+
run(sql, ...params) {
|
|
20
|
+
const result = this.db.prepare(sql).run(...params);
|
|
21
|
+
return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
|
|
22
|
+
}
|
|
23
|
+
/** Multi-statement DDL */
|
|
24
|
+
exec(sql) {
|
|
25
|
+
this.db.exec(sql);
|
|
26
|
+
}
|
|
27
|
+
/** Access the underlying better-sqlite3 instance */
|
|
28
|
+
get raw() {
|
|
29
|
+
return this.db;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let _instance = null;
|
|
33
|
+
export function useDb() {
|
|
34
|
+
if (_instance)
|
|
35
|
+
return _instance;
|
|
36
|
+
const projectDir = getProjectDir();
|
|
37
|
+
const config = readProjectConfig(projectDir);
|
|
38
|
+
const dbRelPath = config.db?.path || 'data/db.sqlite';
|
|
39
|
+
const dbPath = path.resolve(projectDir, dbRelPath);
|
|
40
|
+
// Auto-create directory
|
|
41
|
+
const dbDir = path.dirname(dbPath);
|
|
42
|
+
if (!fs.existsSync(dbDir)) {
|
|
43
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
const db = new Database(dbPath);
|
|
46
|
+
db.pragma('journal_mode = WAL');
|
|
47
|
+
db.pragma('foreign_keys = ON');
|
|
48
|
+
_instance = new LumenDb(db);
|
|
49
|
+
// Run pending migrations
|
|
50
|
+
runMigrations(db, projectDir);
|
|
51
|
+
return _instance;
|
|
52
|
+
}
|
|
53
|
+
function runMigrations(db, projectDir) {
|
|
54
|
+
const migrationsDir = path.join(projectDir, 'data', 'migrations');
|
|
55
|
+
if (!fs.existsSync(migrationsDir))
|
|
56
|
+
return;
|
|
57
|
+
// Ensure tracking table exists
|
|
58
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _lumen_migrations (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
name TEXT NOT NULL UNIQUE,
|
|
61
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
62
|
+
)`);
|
|
63
|
+
const applied = new Set(db.prepare('SELECT name FROM _lumen_migrations').all()
|
|
64
|
+
.map((row) => row.name));
|
|
65
|
+
const files = fs.readdirSync(migrationsDir)
|
|
66
|
+
.filter(f => f.endsWith('.sql'))
|
|
67
|
+
.sort();
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
if (applied.has(file))
|
|
70
|
+
continue;
|
|
71
|
+
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8');
|
|
72
|
+
const migrate = db.transaction(() => {
|
|
73
|
+
db.exec(sql);
|
|
74
|
+
db.prepare('INSERT INTO _lumen_migrations (name) VALUES (?)').run(file);
|
|
75
|
+
});
|
|
76
|
+
migrate();
|
|
77
|
+
console.log(`[LumenJS] Applied migration: ${file}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -52,7 +52,21 @@ export function readProjectConfig(projectDir) {
|
|
|
52
52
|
}
|
|
53
53
|
catch { /* ignore */ }
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
// Parse db config
|
|
56
|
+
let db;
|
|
57
|
+
if (fs.existsSync(configPath)) {
|
|
58
|
+
try {
|
|
59
|
+
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
60
|
+
const dbMatch = configContent.match(/db\s*:\s*\{([\s\S]*?)\}/);
|
|
61
|
+
if (dbMatch) {
|
|
62
|
+
const block = dbMatch[1];
|
|
63
|
+
const pathMatch = block.match(/path\s*:\s*['"]([^'"]+)['"]/);
|
|
64
|
+
db = pathMatch ? { path: pathMatch[1] } : {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch { /* ignore */ }
|
|
68
|
+
}
|
|
69
|
+
return { title, integrations, ...(i18n ? { i18n } : {}), ...(db ? { db } : {}) };
|
|
56
70
|
}
|
|
57
71
|
/**
|
|
58
72
|
* Reads the project title from lumenjs.config.ts (or returns default).
|
|
@@ -15,7 +15,6 @@ import { installDomShims } from '../../shared/dom-shims.js';
|
|
|
15
15
|
* return { item: data, timestamp: Date.now() };
|
|
16
16
|
* }
|
|
17
17
|
*
|
|
18
|
-
* @customElement('page-item')
|
|
19
18
|
* export class PageItem extends LitElement {
|
|
20
19
|
* @property({ type: Object }) loaderData = {};
|
|
21
20
|
* render() {
|
|
@@ -31,6 +30,78 @@ export function lumenLoadersPlugin(pagesDir) {
|
|
|
31
30
|
return {
|
|
32
31
|
name: 'lumenjs-loaders',
|
|
33
32
|
configureServer(server) {
|
|
33
|
+
// SSE subscribe middleware
|
|
34
|
+
server.middlewares.use(async (req, res, next) => {
|
|
35
|
+
if (!req.url?.startsWith('/__nk_subscribe/')) {
|
|
36
|
+
return next();
|
|
37
|
+
}
|
|
38
|
+
const [pathname, queryString] = req.url.split('?');
|
|
39
|
+
// Parse query params
|
|
40
|
+
const query = {};
|
|
41
|
+
if (queryString) {
|
|
42
|
+
for (const pair of queryString.split('&')) {
|
|
43
|
+
const [key, val] = pair.split('=');
|
|
44
|
+
query[decodeURIComponent(key)] = decodeURIComponent(val || '');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Handle layout subscribe: /__nk_subscribe/__layout/?__dir=<dir>
|
|
48
|
+
if (pathname === '/__nk_subscribe/__layout/' || pathname === '/__nk_subscribe/__layout') {
|
|
49
|
+
const dir = query.__dir || '';
|
|
50
|
+
await handleLayoutSubscribe(server, pagesDir, dir, query, req, res);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const pagePath = pathname.replace('/__nk_subscribe', '') || '/';
|
|
54
|
+
// Parse URL params
|
|
55
|
+
let params = {};
|
|
56
|
+
if (query.__params) {
|
|
57
|
+
try {
|
|
58
|
+
params = JSON.parse(query.__params);
|
|
59
|
+
}
|
|
60
|
+
catch { /* ignore */ }
|
|
61
|
+
delete query.__params;
|
|
62
|
+
}
|
|
63
|
+
const filePath = resolvePageFile(pagesDir, pagePath);
|
|
64
|
+
if (!filePath) {
|
|
65
|
+
res.statusCode = 404;
|
|
66
|
+
res.end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (Object.keys(params).length === 0) {
|
|
70
|
+
Object.assign(params, extractRouteParams(pagesDir, pagePath, filePath));
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
installDomShims();
|
|
74
|
+
const mod = await server.ssrLoadModule(filePath);
|
|
75
|
+
if (!mod.subscribe || typeof mod.subscribe !== 'function') {
|
|
76
|
+
res.statusCode = 204;
|
|
77
|
+
res.end();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Set SSE headers
|
|
81
|
+
res.writeHead(200, {
|
|
82
|
+
'Content-Type': 'text/event-stream',
|
|
83
|
+
'Cache-Control': 'no-cache',
|
|
84
|
+
'Connection': 'keep-alive',
|
|
85
|
+
});
|
|
86
|
+
const locale = query.__locale;
|
|
87
|
+
const push = (data) => {
|
|
88
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
89
|
+
};
|
|
90
|
+
const cleanup = mod.subscribe({ params, push, headers: req.headers, locale });
|
|
91
|
+
res.on('close', () => {
|
|
92
|
+
if (typeof cleanup === 'function')
|
|
93
|
+
cleanup();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.error(`[LumenJS] Subscribe error for ${pagePath}:`, err);
|
|
98
|
+
if (!res.headersSent) {
|
|
99
|
+
res.statusCode = 500;
|
|
100
|
+
res.end();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
// Loader middleware
|
|
34
105
|
server.middlewares.use(async (req, res, next) => {
|
|
35
106
|
if (!req.url?.startsWith('/__nk_loader/')) {
|
|
36
107
|
return next();
|
|
@@ -126,45 +197,26 @@ export function lumenLoadersPlugin(pagesDir) {
|
|
|
126
197
|
// Apply to page files and layout files within the pages directory
|
|
127
198
|
if (!id.startsWith(pagesDir) || !id.endsWith('.ts'))
|
|
128
199
|
return;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (!hasLoader)
|
|
133
|
-
return;
|
|
134
|
-
// Find the loader function by tracking brace depth
|
|
135
|
-
const match = code.match(/export\s+(async\s+)?function\s+loader\s*\(/);
|
|
136
|
-
if (!match)
|
|
200
|
+
const hasLoader = code.includes('export') && code.includes('loader') && /export\s+(async\s+)?function\s+loader\s*\(/.test(code);
|
|
201
|
+
const hasSubscribe = code.includes('export') && code.includes('subscribe') && /export\s+(async\s+)?function\s+subscribe\s*\(/.test(code);
|
|
202
|
+
if (!hasLoader && !hasSubscribe)
|
|
137
203
|
return;
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
while (sigIdx < code.length && parenDepth > 0) {
|
|
143
|
-
if (code[sigIdx] === '(')
|
|
144
|
-
parenDepth++;
|
|
145
|
-
else if (code[sigIdx] === ')')
|
|
146
|
-
parenDepth--;
|
|
147
|
-
sigIdx++;
|
|
204
|
+
let result = code;
|
|
205
|
+
// Strip loader function
|
|
206
|
+
if (hasLoader) {
|
|
207
|
+
result = stripServerFunction(result, 'loader');
|
|
148
208
|
}
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
else if (code[i] === '}')
|
|
159
|
-
depth--;
|
|
160
|
-
i++;
|
|
209
|
+
// Strip subscribe function
|
|
210
|
+
if (hasSubscribe) {
|
|
211
|
+
result = stripServerFunction(result, 'subscribe');
|
|
212
|
+
}
|
|
213
|
+
if (hasLoader) {
|
|
214
|
+
result += '\nexport const __nk_has_loader = true;\n';
|
|
215
|
+
}
|
|
216
|
+
if (hasSubscribe) {
|
|
217
|
+
result += '\nexport const __nk_has_subscribe = true;\n';
|
|
161
218
|
}
|
|
162
|
-
|
|
163
|
-
const transformed = code.substring(0, startIdx)
|
|
164
|
-
+ '// loader() — runs server-side only'
|
|
165
|
-
+ code.substring(i);
|
|
166
|
-
const withFlag = transformed + '\nexport const __nk_has_loader = true;\n';
|
|
167
|
-
return { code: withFlag, map: null };
|
|
219
|
+
return { code: result, map: null };
|
|
168
220
|
},
|
|
169
221
|
};
|
|
170
222
|
}
|
|
@@ -310,6 +362,90 @@ function findDynamicPage(baseDir, segments) {
|
|
|
310
362
|
}
|
|
311
363
|
return null;
|
|
312
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* Strip a named server-side function (loader/subscribe) from client code using brace-depth tracking.
|
|
367
|
+
*/
|
|
368
|
+
function stripServerFunction(code, fnName) {
|
|
369
|
+
const regex = new RegExp(`export\\s+(async\\s+)?function\\s+${fnName}\\s*\\(`);
|
|
370
|
+
const match = code.match(regex);
|
|
371
|
+
if (!match)
|
|
372
|
+
return code;
|
|
373
|
+
const startIdx = match.index;
|
|
374
|
+
let parenDepth = 1;
|
|
375
|
+
let sigIdx = startIdx + match[0].length;
|
|
376
|
+
while (sigIdx < code.length && parenDepth > 0) {
|
|
377
|
+
if (code[sigIdx] === '(')
|
|
378
|
+
parenDepth++;
|
|
379
|
+
else if (code[sigIdx] === ')')
|
|
380
|
+
parenDepth--;
|
|
381
|
+
sigIdx++;
|
|
382
|
+
}
|
|
383
|
+
let braceStart = code.indexOf('{', sigIdx);
|
|
384
|
+
if (braceStart === -1)
|
|
385
|
+
return code;
|
|
386
|
+
let depth = 1;
|
|
387
|
+
let i = braceStart + 1;
|
|
388
|
+
while (i < code.length && depth > 0) {
|
|
389
|
+
if (code[i] === '{')
|
|
390
|
+
depth++;
|
|
391
|
+
else if (code[i] === '}')
|
|
392
|
+
depth--;
|
|
393
|
+
i++;
|
|
394
|
+
}
|
|
395
|
+
return code.substring(0, startIdx)
|
|
396
|
+
+ `// ${fnName}() — runs server-side only`
|
|
397
|
+
+ code.substring(i);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Handle layout subscribe requests in dev mode.
|
|
401
|
+
* GET /__nk_subscribe/__layout/?__dir=<dir>
|
|
402
|
+
*/
|
|
403
|
+
async function handleLayoutSubscribe(server, pagesDir, dir, query, req, res) {
|
|
404
|
+
const layoutDir = path.join(pagesDir, dir);
|
|
405
|
+
let layoutFile = null;
|
|
406
|
+
for (const ext of ['.ts', '.js']) {
|
|
407
|
+
const p = path.join(layoutDir, `_layout${ext}`);
|
|
408
|
+
if (fs.existsSync(p)) {
|
|
409
|
+
layoutFile = p;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (!layoutFile) {
|
|
414
|
+
res.statusCode = 204;
|
|
415
|
+
res.end();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
installDomShims();
|
|
420
|
+
const mod = await server.ssrLoadModule(layoutFile);
|
|
421
|
+
if (!mod.subscribe || typeof mod.subscribe !== 'function') {
|
|
422
|
+
res.statusCode = 204;
|
|
423
|
+
res.end();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
res.writeHead(200, {
|
|
427
|
+
'Content-Type': 'text/event-stream',
|
|
428
|
+
'Cache-Control': 'no-cache',
|
|
429
|
+
'Connection': 'keep-alive',
|
|
430
|
+
});
|
|
431
|
+
const locale = query.__locale;
|
|
432
|
+
const push = (data) => {
|
|
433
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
434
|
+
};
|
|
435
|
+
const cleanup = mod.subscribe({ params: {}, push, headers: req.headers, locale });
|
|
436
|
+
res.on('close', () => {
|
|
437
|
+
if (typeof cleanup === 'function')
|
|
438
|
+
cleanup();
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
console.error(`[LumenJS] Layout subscribe error for dir=${dir}:`, err);
|
|
443
|
+
if (!res.headersSent) {
|
|
444
|
+
res.statusCode = 500;
|
|
445
|
+
res.end();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
313
449
|
/**
|
|
314
450
|
* Extract dynamic route params by comparing URL segments against [param] file path segments.
|
|
315
451
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { dirToLayoutTagName, fileHasLoader, filePathToRoute, filePathToTagName } from '../../shared/utils.js';
|
|
3
|
+
import { dirToLayoutTagName, fileHasLoader, fileHasSubscribe, filePathToRoute, filePathToTagName } from '../../shared/utils.js';
|
|
4
4
|
const VIRTUAL_MODULE_ID = 'virtual:lumenjs-routes';
|
|
5
5
|
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
|
|
6
6
|
/**
|
|
@@ -98,18 +98,20 @@ export function lumenRoutesPlugin(pagesDir) {
|
|
|
98
98
|
const routeArray = routes
|
|
99
99
|
.map(r => {
|
|
100
100
|
const hasLoader = fileHasLoader(r.componentPath);
|
|
101
|
+
const hasSubscribe = fileHasSubscribe(r.componentPath);
|
|
101
102
|
const componentPath = r.componentPath.replace(/\\/g, '/');
|
|
102
103
|
const chain = getLayoutChain(r.componentPath, layouts);
|
|
103
104
|
let layoutsStr = '';
|
|
104
105
|
if (chain.length > 0) {
|
|
105
106
|
const items = chain.map(l => {
|
|
106
107
|
const lHasLoader = fileHasLoader(l.filePath);
|
|
108
|
+
const lHasSubscribe = fileHasSubscribe(l.filePath);
|
|
107
109
|
const lPath = l.filePath.replace(/\\/g, '/');
|
|
108
|
-
return `{ tagName: ${JSON.stringify(l.tagName)}, loaderPath: ${JSON.stringify(l.dir)}${lHasLoader ? ', hasLoader: true' : ''}, load: () => import('${lPath}') }`;
|
|
110
|
+
return `{ tagName: ${JSON.stringify(l.tagName)}, loaderPath: ${JSON.stringify(l.dir)}${lHasLoader ? ', hasLoader: true' : ''}${lHasSubscribe ? ', hasSubscribe: true' : ''}, load: () => import('${lPath}') }`;
|
|
109
111
|
});
|
|
110
112
|
layoutsStr = `, layouts: [${items.join(', ')}]`;
|
|
111
113
|
}
|
|
112
|
-
return ` { path: ${JSON.stringify(r.path)}, tagName: ${JSON.stringify(r.tagName)}${hasLoader ? ', hasLoader: true' : ''}, load: () => import('${componentPath}')${layoutsStr} }`;
|
|
114
|
+
return ` { path: ${JSON.stringify(r.path)}, tagName: ${JSON.stringify(r.tagName)}${hasLoader ? ', hasLoader: true' : ''}${hasSubscribe ? ', hasSubscribe: true' : ''}, load: () => import('${componentPath}')${layoutsStr} }`;
|
|
113
115
|
})
|
|
114
116
|
.join(',\n');
|
|
115
117
|
return `export const routes = [\n${routeArray}\n];\n`;
|
|
@@ -17,6 +17,7 @@ import { sourceAnnotatorPlugin } from './plugins/vite-plugin-source-annotator.js
|
|
|
17
17
|
import { virtualModulesPlugin } from './plugins/vite-plugin-virtual-modules.js';
|
|
18
18
|
import { i18nPlugin, loadTranslationsFromDisk } from './plugins/vite-plugin-i18n.js';
|
|
19
19
|
import { resolveLocale } from './middleware/locale.js';
|
|
20
|
+
import { setProjectDir } from '../db/context.js';
|
|
20
21
|
// Re-export for backwards compatibility
|
|
21
22
|
export { readProjectConfig, readProjectTitle, getLumenJSNodeModules, getLumenJSDirs } from './config.js';
|
|
22
23
|
export { getNuralyUIAliases, resolveNuralyUIPaths } from './nuralyui-aliases.js';
|
|
@@ -82,6 +83,7 @@ export function getSharedViteConfig(projectDir, options) {
|
|
|
82
83
|
}
|
|
83
84
|
export async function createDevServer(options) {
|
|
84
85
|
const { projectDir, port, editorMode = false, base = '/' } = options;
|
|
86
|
+
setProjectDir(projectDir);
|
|
85
87
|
const pagesDir = path.join(projectDir, 'pages');
|
|
86
88
|
const apiDir = path.join(projectDir, 'api');
|
|
87
89
|
const publicDir = path.join(projectDir, 'public');
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export declare function fetchLoaderData(pathname: string, params: Record<string, string>): Promise<any>;
|
|
2
2
|
export declare function fetchLayoutLoaderData(dir: string): Promise<any>;
|
|
3
|
+
export declare function connectSubscribe(pathname: string, params: Record<string, string>): EventSource;
|
|
4
|
+
export declare function connectLayoutSubscribe(dir: string): EventSource;
|
|
3
5
|
export declare function render404(pathname: string): string;
|
|
@@ -33,6 +33,26 @@ export async function fetchLayoutLoaderData(dir) {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
return data;
|
|
35
35
|
}
|
|
36
|
+
export function connectSubscribe(pathname, params) {
|
|
37
|
+
const url = new URL(`/__nk_subscribe${pathname}`, location.origin);
|
|
38
|
+
if (Object.keys(params).length > 0) {
|
|
39
|
+
url.searchParams.set('__params', JSON.stringify(params));
|
|
40
|
+
}
|
|
41
|
+
const config = getI18nConfig();
|
|
42
|
+
if (config) {
|
|
43
|
+
url.searchParams.set('__locale', getLocale());
|
|
44
|
+
}
|
|
45
|
+
return new EventSource(url.toString());
|
|
46
|
+
}
|
|
47
|
+
export function connectLayoutSubscribe(dir) {
|
|
48
|
+
const url = new URL('/__nk_subscribe/__layout/', location.origin);
|
|
49
|
+
url.searchParams.set('__dir', dir);
|
|
50
|
+
const config = getI18nConfig();
|
|
51
|
+
if (config) {
|
|
52
|
+
url.searchParams.set('__locale', getLocale());
|
|
53
|
+
}
|
|
54
|
+
return new EventSource(url.toString());
|
|
55
|
+
}
|
|
36
56
|
export function render404(pathname) {
|
|
37
57
|
return `<div style="display:flex;align-items:center;justify-content:center;min-height:80vh;font-family:system-ui,-apple-system,sans-serif;padding:2rem">
|
|
38
58
|
<div style="text-align:center;max-width:400px">
|
package/dist/runtime/router.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export interface LayoutInfo {
|
|
2
2
|
tagName: string;
|
|
3
3
|
hasLoader?: boolean;
|
|
4
|
+
hasSubscribe?: boolean;
|
|
4
5
|
load?: () => Promise<any>;
|
|
5
6
|
loaderPath?: string;
|
|
6
7
|
}
|
|
@@ -8,6 +9,7 @@ export interface Route {
|
|
|
8
9
|
path: string;
|
|
9
10
|
tagName: string;
|
|
10
11
|
hasLoader?: boolean;
|
|
12
|
+
hasSubscribe?: boolean;
|
|
11
13
|
load?: () => Promise<any>;
|
|
12
14
|
layouts?: LayoutInfo[];
|
|
13
15
|
pattern?: RegExp;
|
|
@@ -23,14 +25,17 @@ export declare class NkRouter {
|
|
|
23
25
|
private outlet;
|
|
24
26
|
private currentTag;
|
|
25
27
|
private currentLayoutTags;
|
|
28
|
+
private subscriptions;
|
|
26
29
|
params: Record<string, string>;
|
|
27
30
|
constructor(routes: Route[], outlet: HTMLElement, hydrate?: boolean);
|
|
28
31
|
private compilePattern;
|
|
32
|
+
private cleanupSubscriptions;
|
|
29
33
|
navigate(pathname: string, pushState?: boolean): Promise<void>;
|
|
30
34
|
private matchRoute;
|
|
31
35
|
private renderRoute;
|
|
32
36
|
private buildLayoutTree;
|
|
33
37
|
private createPageElement;
|
|
38
|
+
private findPageElement;
|
|
34
39
|
private handleLinkClick;
|
|
35
40
|
/** Strip locale prefix from a path for internal route matching. */
|
|
36
41
|
private stripLocale;
|
package/dist/runtime/router.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchLoaderData, fetchLayoutLoaderData, render404 } from './router-data.js';
|
|
1
|
+
import { fetchLoaderData, fetchLayoutLoaderData, connectSubscribe, connectLayoutSubscribe, render404 } from './router-data.js';
|
|
2
2
|
import { hydrateInitialRoute } from './router-hydration.js';
|
|
3
3
|
import { getI18nConfig, getLocale, stripLocalePrefix, buildLocalePath } from './i18n.js';
|
|
4
4
|
/**
|
|
@@ -12,6 +12,7 @@ export class NkRouter {
|
|
|
12
12
|
this.outlet = null;
|
|
13
13
|
this.currentTag = null;
|
|
14
14
|
this.currentLayoutTags = [];
|
|
15
|
+
this.subscriptions = [];
|
|
15
16
|
this.params = {};
|
|
16
17
|
this.outlet = outlet;
|
|
17
18
|
this.routes = routes.map(r => ({
|
|
@@ -43,7 +44,14 @@ export class NkRouter {
|
|
|
43
44
|
});
|
|
44
45
|
return { pattern: new RegExp(`^${pattern}$`), paramNames };
|
|
45
46
|
}
|
|
47
|
+
cleanupSubscriptions() {
|
|
48
|
+
for (const es of this.subscriptions) {
|
|
49
|
+
es.close();
|
|
50
|
+
}
|
|
51
|
+
this.subscriptions = [];
|
|
52
|
+
}
|
|
46
53
|
async navigate(pathname, pushState = true) {
|
|
54
|
+
this.cleanupSubscriptions();
|
|
47
55
|
const match = this.matchRoute(pathname);
|
|
48
56
|
if (!match) {
|
|
49
57
|
if (this.outlet)
|
|
@@ -55,6 +63,7 @@ export class NkRouter {
|
|
|
55
63
|
if (pushState) {
|
|
56
64
|
const localePath = this.withLocale(pathname);
|
|
57
65
|
history.pushState(null, '', localePath);
|
|
66
|
+
window.scrollTo(0, 0);
|
|
58
67
|
}
|
|
59
68
|
this.params = match.params;
|
|
60
69
|
// Lazy-load the page component if not yet registered
|
|
@@ -96,6 +105,28 @@ export class NkRouter {
|
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
107
|
this.renderRoute(match.route, loaderData, layouts, layoutDataList);
|
|
108
|
+
// Set up SSE subscriptions for page
|
|
109
|
+
if (match.route.hasSubscribe) {
|
|
110
|
+
const es = connectSubscribe(pathname, match.params);
|
|
111
|
+
es.onmessage = (e) => {
|
|
112
|
+
const pageEl = this.findPageElement(match.route.tagName);
|
|
113
|
+
if (pageEl)
|
|
114
|
+
pageEl.liveData = JSON.parse(e.data);
|
|
115
|
+
};
|
|
116
|
+
this.subscriptions.push(es);
|
|
117
|
+
}
|
|
118
|
+
// Set up SSE subscriptions for layouts
|
|
119
|
+
for (const layout of layouts) {
|
|
120
|
+
if (layout.hasSubscribe) {
|
|
121
|
+
const es = connectLayoutSubscribe(layout.loaderPath || '');
|
|
122
|
+
es.onmessage = (e) => {
|
|
123
|
+
const layoutEl = this.outlet?.querySelector(layout.tagName);
|
|
124
|
+
if (layoutEl)
|
|
125
|
+
layoutEl.liveData = JSON.parse(e.data);
|
|
126
|
+
};
|
|
127
|
+
this.subscriptions.push(es);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
99
130
|
}
|
|
100
131
|
matchRoute(pathname) {
|
|
101
132
|
for (const route of this.routes) {
|
|
@@ -194,6 +225,11 @@ export class NkRouter {
|
|
|
194
225
|
}
|
|
195
226
|
return el;
|
|
196
227
|
}
|
|
228
|
+
findPageElement(tagName) {
|
|
229
|
+
if (!this.outlet)
|
|
230
|
+
return null;
|
|
231
|
+
return this.outlet.querySelector(tagName) ?? this.outlet.querySelector(`${tagName}:last-child`);
|
|
232
|
+
}
|
|
197
233
|
handleLinkClick(event) {
|
|
198
234
|
const path = event.composedPath();
|
|
199
235
|
const anchor = path.find((el) => el instanceof HTMLElement && el.tagName === 'A');
|
package/dist/shared/types.d.ts
CHANGED
|
@@ -2,11 +2,13 @@ export interface ManifestLayout {
|
|
|
2
2
|
dir: string;
|
|
3
3
|
module: string;
|
|
4
4
|
hasLoader: boolean;
|
|
5
|
+
hasSubscribe: boolean;
|
|
5
6
|
}
|
|
6
7
|
export interface ManifestRoute {
|
|
7
8
|
path: string;
|
|
8
9
|
module: string;
|
|
9
10
|
hasLoader: boolean;
|
|
11
|
+
hasSubscribe: boolean;
|
|
10
12
|
tagName?: string;
|
|
11
13
|
layouts?: string[];
|
|
12
14
|
}
|
package/dist/shared/utils.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare function stripOuterLitMarkers(html: string): string;
|
|
|
14
14
|
export declare function dirToLayoutTagName(dir: string): string;
|
|
15
15
|
/**
|
|
16
16
|
* Find the custom element tag name from a page module.
|
|
17
|
-
* Pages
|
|
17
|
+
* Pages are auto-registered by the auto-define plugin based on file path.
|
|
18
18
|
*/
|
|
19
19
|
export declare function findTagName(mod: Record<string, any>): string | null;
|
|
20
20
|
/**
|
|
@@ -43,6 +43,10 @@ export declare function escapeHtml(text: string): string;
|
|
|
43
43
|
* Check if a page/layout file exports a loader() function.
|
|
44
44
|
*/
|
|
45
45
|
export declare function fileHasLoader(filePath: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Check if a page/layout file exports a subscribe() function.
|
|
48
|
+
*/
|
|
49
|
+
export declare function fileHasSubscribe(filePath: string): boolean;
|
|
46
50
|
/**
|
|
47
51
|
* Convert a file path (relative to pages/) to a route path.
|
|
48
52
|
*/
|
package/dist/shared/utils.js
CHANGED
|
@@ -29,7 +29,7 @@ export function dirToLayoutTagName(dir) {
|
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
31
|
* Find the custom element tag name from a page module.
|
|
32
|
-
* Pages
|
|
32
|
+
* Pages are auto-registered by the auto-define plugin based on file path.
|
|
33
33
|
*/
|
|
34
34
|
export function findTagName(mod) {
|
|
35
35
|
for (const key of Object.keys(mod)) {
|
|
@@ -109,6 +109,18 @@ export function fileHasLoader(filePath) {
|
|
|
109
109
|
return false;
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Check if a page/layout file exports a subscribe() function.
|
|
114
|
+
*/
|
|
115
|
+
export function fileHasSubscribe(filePath) {
|
|
116
|
+
try {
|
|
117
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
118
|
+
return /export\s+(async\s+)?function\s+subscribe\s*\(/.test(content);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
112
124
|
/**
|
|
113
125
|
* Convert a file path (relative to pages/) to a route path.
|
|
114
126
|
*/
|
package/package.json
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuraly/lumenjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"lumenjs": "dist/cli.js"
|
|
9
9
|
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/cli.js",
|
|
12
|
+
"./db": "./dist/db/index.js"
|
|
13
|
+
},
|
|
10
14
|
"files": [
|
|
11
15
|
"dist",
|
|
16
|
+
"templates",
|
|
12
17
|
"README.md"
|
|
13
18
|
],
|
|
14
19
|
"scripts": {
|
|
@@ -42,11 +47,13 @@
|
|
|
42
47
|
"license": "MIT",
|
|
43
48
|
"dependencies": {
|
|
44
49
|
"@lit-labs/ssr": "^3.2.0",
|
|
50
|
+
"better-sqlite3": "^11.0.0",
|
|
45
51
|
"glob": "^10.3.0",
|
|
46
52
|
"lit": "^3.1.0",
|
|
47
53
|
"vite": "^5.4.0"
|
|
48
54
|
},
|
|
49
55
|
"devDependencies": {
|
|
56
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
50
57
|
"@types/node": "^20.14.2",
|
|
51
58
|
"@vitest/coverage-v8": "^4.0.18",
|
|
52
59
|
"typescript": "^5.4.5",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useDb } from '@nuraly/lumenjs/db';
|
|
2
|
+
|
|
3
|
+
export function GET() {
|
|
4
|
+
const db = useDb();
|
|
5
|
+
const posts = db.all('SELECT id, title, slug, content, date FROM posts ORDER BY date DESC');
|
|
6
|
+
return { posts };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function POST(req: { body: any }) {
|
|
10
|
+
const { title, slug, content } = req.body;
|
|
11
|
+
if (!title || !slug || !content) {
|
|
12
|
+
throw { status: 400, message: 'title, slug, and content are required' };
|
|
13
|
+
}
|
|
14
|
+
const db = useDb();
|
|
15
|
+
const result = db.run(
|
|
16
|
+
'INSERT INTO posts (title, slug, content) VALUES (?, ?, ?)',
|
|
17
|
+
title, slug, content
|
|
18
|
+
);
|
|
19
|
+
return { id: result.lastInsertRowid, title, slug, content };
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS posts (
|
|
2
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3
|
+
title TEXT NOT NULL,
|
|
4
|
+
slug TEXT NOT NULL UNIQUE,
|
|
5
|
+
content TEXT NOT NULL,
|
|
6
|
+
date TEXT NOT NULL DEFAULT (date('now'))
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
INSERT INTO posts (title, slug, content, date) VALUES
|
|
10
|
+
('Hello World', 'hello-world', 'Welcome to your new LumenJS blog! This post was loaded from SQLite.', '2025-01-15'),
|
|
11
|
+
('Getting Started with LumenJS', 'getting-started', 'LumenJS makes it easy to build full-stack web apps with Lit web components and file-based routing.', '2025-01-20'),
|
|
12
|
+
('SQLite Persistence', 'sqlite-persistence', 'LumenJS includes built-in SQLite support via better-sqlite3. Just use useDb() in your loaders and API routes.', '2025-01-25');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
import { useDb } from '@nuraly/lumenjs/db';
|
|
4
|
+
|
|
5
|
+
export function loader() {
|
|
6
|
+
const db = useDb();
|
|
7
|
+
const posts = db.all('SELECT id, title, slug, content, date FROM posts ORDER BY date DESC');
|
|
8
|
+
return { posts };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@customElement('page-home')
|
|
12
|
+
export class PageHome extends LitElement {
|
|
13
|
+
@property({ type: Object }) data: any;
|
|
14
|
+
|
|
15
|
+
static styles = css`
|
|
16
|
+
:host { display: block; max-width: 720px; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif; }
|
|
17
|
+
h1 { font-size: 2rem; margin-bottom: 1.5rem; }
|
|
18
|
+
.post { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid #eee; }
|
|
19
|
+
.post h2 { margin: 0 0 0.25rem; }
|
|
20
|
+
.post a { color: #0066cc; text-decoration: none; }
|
|
21
|
+
.post a:hover { text-decoration: underline; }
|
|
22
|
+
.post .date { color: #666; font-size: 0.875rem; }
|
|
23
|
+
.post p { color: #333; margin: 0.5rem 0 0; }
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
render() {
|
|
27
|
+
const posts = this.data?.posts || [];
|
|
28
|
+
return html`
|
|
29
|
+
<h1>Blog</h1>
|
|
30
|
+
${posts.map((post: any) => html`
|
|
31
|
+
<div class="post">
|
|
32
|
+
<h2><a href="/posts/${post.slug}">${post.title}</a></h2>
|
|
33
|
+
<span class="date">${post.date}</span>
|
|
34
|
+
<p>${post.content}</p>
|
|
35
|
+
</div>
|
|
36
|
+
`)}
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
import { useDb } from '@nuraly/lumenjs/db';
|
|
4
|
+
|
|
5
|
+
export function loader({ params }: { params: { slug: string } }) {
|
|
6
|
+
const db = useDb();
|
|
7
|
+
const post = db.get('SELECT id, title, slug, content, date FROM posts WHERE slug = ?', params.slug);
|
|
8
|
+
return { post: post || null };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@customElement('page-post')
|
|
12
|
+
export class PagePost extends LitElement {
|
|
13
|
+
@property({ type: Object }) data: any;
|
|
14
|
+
|
|
15
|
+
static styles = css`
|
|
16
|
+
:host { display: block; max-width: 720px; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif; }
|
|
17
|
+
a { color: #0066cc; text-decoration: none; }
|
|
18
|
+
a:hover { text-decoration: underline; }
|
|
19
|
+
.date { color: #666; font-size: 0.875rem; }
|
|
20
|
+
.content { margin-top: 1rem; line-height: 1.6; color: #333; }
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
render() {
|
|
24
|
+
const post = this.data?.post;
|
|
25
|
+
if (!post) {
|
|
26
|
+
return html`<p>Post not found. <a href="/">Back to blog</a></p>`;
|
|
27
|
+
}
|
|
28
|
+
return html`
|
|
29
|
+
<a href="/">← Back to blog</a>
|
|
30
|
+
<h1>${post.title}</h1>
|
|
31
|
+
<span class="date">${post.date}</span>
|
|
32
|
+
<div class="content">${post.content}</div>
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS stats (
|
|
2
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3
|
+
label TEXT NOT NULL UNIQUE,
|
|
4
|
+
value REAL NOT NULL,
|
|
5
|
+
unit TEXT NOT NULL DEFAULT '',
|
|
6
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
INSERT INTO stats (label, value, unit) VALUES
|
|
10
|
+
('Total Users', 1284, 'users'),
|
|
11
|
+
('Revenue', 42500, 'USD'),
|
|
12
|
+
('Active Sessions', 89, 'sessions'),
|
|
13
|
+
('Uptime', 99.97, '%');
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
import { useDb } from '@nuraly/lumenjs/db';
|
|
4
|
+
|
|
5
|
+
export function loader() {
|
|
6
|
+
const db = useDb();
|
|
7
|
+
const stats = db.all('SELECT id, label, value, unit, updated_at FROM stats ORDER BY id');
|
|
8
|
+
return { stats };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@customElement('page-dashboard')
|
|
12
|
+
export class PageDashboard extends LitElement {
|
|
13
|
+
@property({ type: Object }) data: any;
|
|
14
|
+
|
|
15
|
+
static styles = css`
|
|
16
|
+
:host { display: block; max-width: 960px; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif; }
|
|
17
|
+
h1 { font-size: 2rem; margin-bottom: 1.5rem; }
|
|
18
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
|
19
|
+
.card { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1.5rem; }
|
|
20
|
+
.card .label { font-size: 0.875rem; color: #6b7280; margin-bottom: 0.25rem; }
|
|
21
|
+
.card .value { font-size: 1.75rem; font-weight: 600; color: #111827; }
|
|
22
|
+
.card .unit { font-size: 0.75rem; color: #9ca3af; margin-left: 0.25rem; }
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
render() {
|
|
26
|
+
const stats = this.data?.stats || [];
|
|
27
|
+
return html`
|
|
28
|
+
<h1>Dashboard</h1>
|
|
29
|
+
<div class="grid">
|
|
30
|
+
${stats.map((stat: any) => html`
|
|
31
|
+
<div class="card">
|
|
32
|
+
<div class="label">${stat.label}</div>
|
|
33
|
+
<div class="value">
|
|
34
|
+
${stat.value}<span class="unit">${stat.unit}</span>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
`)}
|
|
38
|
+
</div>
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
41
|
+
}
|