@nuraly/lumenjs
v0.1.3
Published
Full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes
Maintainers
Readme
LumenJS
A full-stack web framework for Lit web components. File-based routing, server loaders, real-time subscriptions (SSE), SSR with hydration, nested layouts, API routes, and a Vite-powered dev server.
Quick Start
npx lumenjs dev --project ./my-appProject Structure
my-app/
├── lumenjs.config.ts # Project config
├── package.json
├── pages/ # File-based routes
│ ├── _layout.ts # Root layout
│ ├── index.ts # → /
│ ├── about.ts # → /about
│ └── blog/
│ ├── _layout.ts # Nested layout (wraps blog/*)
│ ├── index.ts # → /blog
│ └── [slug].ts # → /blog/:slug
├── api/ # API routes
│ └── hello.ts # → /api/hello
└── public/ # Static assetsConfiguration
// lumenjs.config.ts
export default {
title: 'My App',
integrations: ['tailwind'],
};| Option | Type | Description |
|---|---|---|
| title | string | HTML page title |
| integrations | string[] | Optional integrations: 'tailwind', 'nuralyui' |
Pages
Pages are Lit components in the pages/ directory. The file path determines the URL.
// pages/index.ts
import { LitElement, html, css } from 'lit';
export class PageIndex extends LitElement {
static styles = css`:host { display: block; }`;
render() {
return html`<h1>Hello, LumenJS!</h1>`;
}
}The custom element tag name is derived automatically from the file path — no @customElement decorator needed.
Routing
| File | URL | Tag |
|---|---|---|
| pages/index.ts | / | <page-index> |
| pages/about.ts | /about | <page-about> |
| pages/blog/index.ts | /blog | <page-blog-index> |
| pages/blog/[slug].ts | /blog/:slug | <page-blog-slug> |
| pages/[...slug].ts | /* (catch-all) | <page-slug> |
Static routes take priority over dynamic ones. Dynamic [param] routes take priority over catch-all [...param] routes.
Loaders
Export a loader() function from any page or layout to fetch data on the server.
// pages/blog/[slug].ts
export async function loader({ params, headers, query, url }) {
const post = await db.posts.findOne({ slug: params.slug });
if (!post) return { __nk_redirect: true, location: '/404', status: 302 };
return { post };
}
export class BlogPost extends LitElement {
static properties = { loaderData: { type: Object } };
loaderData: any = {};
render() {
return html`<h1>${this.loaderData.post?.title}</h1>`;
}
}Loaders run server-side on initial load (SSR) and are fetched via /__nk_loader/<path> during client-side navigation. The loader() export is automatically stripped from client bundles.
Loader Context
| Property | Type | Description |
|---|---|---|
| params | Record<string, string> | Dynamic route parameters |
| query | Record<string, string> | Query string parameters |
| url | string | Request pathname |
| headers | Record<string, any> | Request headers |
| locale | string | Current locale (when i18n is configured) |
Redirects
export async function loader({ headers }) {
const user = await getUser(headers.authorization);
if (!user) return { __nk_redirect: true, location: '/login', status: 302 };
return { user };
}Live Data (subscribe)
Export a subscribe() function from any page or layout to push real-time data to the client over Server-Sent Events (SSE).
// pages/dashboard.ts
export async function loader() {
return { orders: await db.orders.findAll() };
}
export function subscribe({ push }) {
const stream = db.orders.watch();
stream.on('change', (change) => push({ type: 'order-update', data: change }));
return () => stream.close();
}
export class PageDashboard extends LitElement {
static properties = { loaderData: { type: Object }, liveData: { type: Object } };
loaderData: any = {};
liveData: any = null;
render() {
return html`
<h1>Orders (${this.loaderData.orders?.length})</h1>
${this.liveData ? html`<p>Update: ${this.liveData.type}</p>` : ''}
`;
}
}The subscribe() function is a persistent server-side process tied to the page lifecycle:
- User opens page → framework opens SSE connection to
/__nk_subscribe/<path> - Server calls
subscribe()— function keeps running (DB watchers, intervals, etc.) - Call
push(data)whenever you want → delivered to client → updatesliveDataproperty - User navigates away → connection closes → cleanup function runs
Like loader(), subscribe() is stripped from client bundles automatically.
Subscribe Context
| Property | Type | Description |
|---|---|---|
| params | Record<string, string> | Dynamic route parameters |
| headers | Record<string, any> | Request headers |
| locale | string | Current locale (when i18n is configured) |
| push | (data: any) => void | Send SSE event to client (JSON-serialized) |
Return a cleanup function that is called when the client disconnects.
Layout Subscribe
Layouts can also export subscribe() for global live data (notifications, presence, etc.):
// pages/_layout.ts
export function subscribe({ push }) {
const ws = new WebSocket('wss://notifications.example.com');
ws.on('message', (msg) => push(JSON.parse(msg)));
return () => ws.close();
}Nested Layouts
Create _layout.ts in any directory to wrap all pages in that directory and its subdirectories.
// pages/_layout.ts
export class RootLayout extends LitElement {
render() {
return html`
<header>My App</header>
<main><slot></slot></main>
<footer>Footer</footer>
`;
}
}Layouts persist across navigation — when navigating between pages that share the same layout, only the page component is swapped.
Layouts can have their own loader() function for shared data like auth or navigation:
// pages/dashboard/_layout.ts
export async function loader({ headers }) {
const user = await getUser(headers.authorization);
if (!user) return { __nk_redirect: true, location: '/login', status: 302 };
return { user };
}
export class DashboardLayout extends LitElement {
static properties = { loaderData: { type: Object } };
loaderData: any = {};
render() {
return html`
<nav>Welcome, ${this.loaderData.user?.name}</nav>
<slot></slot>
`;
}
}API Routes
Create files in api/ and export named functions for each HTTP method.
// api/users/[id].ts
export async function GET(req) {
return { user: { id: req.params.id, name: 'Alice' } };
}
export async function POST(req) {
const { name } = req.body;
return { created: true, name };
}Request Object
| Property | Type | Description |
|---|---|---|
| method | string | HTTP method |
| url | string | Request pathname |
| query | Record<string, string> | Query string parameters |
| params | Record<string, string> | Dynamic route parameters |
| body | any | Parsed JSON body (non-GET) |
| files | NkUploadedFile[] | Uploaded files (multipart) |
| headers | Record<string, any> | Request headers |
Error Responses
export async function GET(req) {
const item = await db.find(req.params.id);
if (!item) throw { status: 404, message: 'Not found' };
return item;
}File Uploads
Multipart form data is parsed automatically:
export async function POST(req) {
for (const file of req.files) {
console.log(file.fileName, file.size, file.contentType);
// file.data is a Buffer
}
return { uploaded: req.files.length };
}SSR & Hydration
Pages with loaders are automatically server-rendered using @lit-labs/ssr:
- Loader runs on the server
- Lit component renders to HTML
- Loader data is embedded as JSON in the response
- Browser receives pre-rendered HTML (fast first paint)
- Client hydrates the existing DOM without re-rendering
Pages without loaders render client-side only (SPA mode). If SSR fails, LumenJS falls back gracefully to client-side rendering.
Internationalization (i18n)
LumenJS has built-in i18n support with URL-prefix-based locale routing.
Setup
- Add i18n config to
lumenjs.config.ts:
export default {
title: 'My App',
i18n: {
locales: ['en', 'fr'],
defaultLocale: 'en',
prefixDefault: false, // / instead of /en/
},
};- Create translation files in
locales/:
my-app/
├── locales/
│ ├── en.json # { "home.title": "Welcome", "nav.docs": "Docs" }
│ └── fr.json # { "home.title": "Bienvenue", "nav.docs": "Documentation" }
├── pages/
└── lumenjs.config.tsUsage
import { t, getLocale, setLocale } from '@lumenjs/i18n';
export class PageIndex extends LitElement {
render() {
return html`<h1>${t('home.title')}</h1>`;
}
}API
| Function | Description |
|---|---|
| t(key) | Returns the translated string for the key, or the key itself if not found |
| getLocale() | Returns the current locale string |
| setLocale(locale) | Switches locale — sets cookie, navigates to the localized URL |
Locale Resolution
Locale is resolved in this order:
- URL prefix:
/fr/about→ localefr, pathname/about - Cookie
nk-locale(set on explicit locale switch) Accept-Languageheader (SSR)- Config
defaultLocale
URL Routing
With prefixDefault: false, the default locale uses clean URLs:
| URL | Locale | Page |
|---|---|---|
| /about | en (default) | pages/about.ts |
| /fr/about | fr | pages/about.ts |
Routes are locale-agnostic — you don't need separate pages per locale. The router strips the locale prefix before matching and prepends it during navigation.
SSR
Translations are server-rendered. The <html lang="..."> attribute is set dynamically, and translations are inlined in the response for hydration without flash of untranslated content.
Integrations
Tailwind CSS
npx lumenjs add tailwindThis installs tailwindcss and @tailwindcss/vite, creates styles/tailwind.css, and updates your config. For pages using Tailwind classes in light DOM:
createRenderRoot() { return this; }NuralyUI
Add 'nuralyui' to integrations to enable auto-import of <nr-*> components:
// lumenjs.config.ts
export default {
title: 'My App',
integrations: ['nuralyui'],
};NuralyUI components are detected in html\`templates and imported automatically, including implicit dependencies (e.g.,nr-buttonauto-importsnr-icon`).
CLI
lumenjs dev [--project <dir>] [--port <port>] [--base <path>] [--editor-mode]
lumenjs build [--project <dir>] [--out <dir>]
lumenjs serve [--project <dir>] [--port <port>]
lumenjs add <integration>| Command | Description |
|---|---|
| dev | Start Vite dev server with HMR, SSR, and API routes |
| build | Bundle client assets and server modules for production |
| serve | Serve the production build with SSR and gzip compression |
| add | Add an integration (e.g., tailwind) |
Default Ports
| Mode | Default |
|---|---|
| dev | 3000 |
| serve | 3000 |
Production Build
npx lumenjs build --project ./my-app
npx lumenjs serve --project ./my-app --port 8080The build outputs to .lumenjs/:
.lumenjs/
├── client/ # Static assets (HTML, JS, CSS)
├── server/ # Server modules (loaders, API routes, SSR runtime)
└── manifest.json # Route manifestThe production server includes gzip compression and serves pre-built assets while executing loaders and API routes on demand.
License
MIT
