noxt-server 0.1.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 +118 -0
- package/bin/noxt-server +65 -0
- package/config.js +83 -0
- package/default.config.yaml +7 -0
- package/index.js +88 -0
- package/package.json +45 -0
- package/reload_client.js +15 -0
- package/reload_server.js +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# noxt-js
|
|
2
|
+
|
|
3
|
+
A zero-config JSX web server powered by [Express](https://expressjs.com/) and [noxt-js-middleware](https://npmjs.com/package/noxt-js-middleware).
|
|
4
|
+
Run it with `npx noxt-js` and drop `.jsx` files into your `views/` folder — routes are created automatically.
|
|
5
|
+
|
|
6
|
+
No React required. No heavy framework. Just JSX + Express.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install noxt-js
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
or run it directly without installing:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx noxt-js
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npx noxt-js
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
By default, this will:
|
|
31
|
+
|
|
32
|
+
- Serve pages from `./views/`
|
|
33
|
+
- Start an HTTP server on port `3000`
|
|
34
|
+
- Look for configuration in `noxt.config.yaml` (optional)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
`noxt-js` reads options from `noxt.config.yaml` in your project root, or from CLI flags.
|
|
41
|
+
CLI flags override config file values.
|
|
42
|
+
|
|
43
|
+
Example `noxt.config.yaml`:
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
port: 4000
|
|
47
|
+
host: localhost
|
|
48
|
+
views: views
|
|
49
|
+
logLevel: info
|
|
50
|
+
ssl: false
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Run with CLI overrides:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npx noxt-js --port 8080 --views src/pages
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Available options
|
|
60
|
+
|
|
61
|
+
- **`port`**: HTTP port number (default: `3000`)
|
|
62
|
+
- **`host`**: Hostname or IP (default: `0.0.0.0`)
|
|
63
|
+
- **`views`**: Directory containing `.jsx` files (default: `views`)
|
|
64
|
+
- **`logLevel`**: One of `error`, `warn`, `info`, `debug`
|
|
65
|
+
- **`ssl`**:
|
|
66
|
+
- `false` (disable SSL)
|
|
67
|
+
- or object with `cert` and `key` paths for HTTPS
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Context
|
|
72
|
+
|
|
73
|
+
You can provide shared helpers/utilities to all components via a `context.js` file (or any path you specify in config). For example:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
// context.js
|
|
77
|
+
export async function fetchUser(id) {
|
|
78
|
+
return db.users.findById(id);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Then in a page:
|
|
83
|
+
|
|
84
|
+
```jsx
|
|
85
|
+
export const route = '/user/:id';
|
|
86
|
+
|
|
87
|
+
export default async function UserPage({ id }, { fetchUser }) {
|
|
88
|
+
const user = await fetchUser(id);
|
|
89
|
+
return <h1>{user.name}</h1>;
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Pages & Routing
|
|
96
|
+
|
|
97
|
+
- Any `.jsx` file in `views/` is loaded as a component.
|
|
98
|
+
- If it exports `route`, it becomes a page at that route.
|
|
99
|
+
- Props come from route params, query string, and optional `params` export.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
|
|
103
|
+
```jsx
|
|
104
|
+
export const route = '/hello/:name';
|
|
105
|
+
|
|
106
|
+
export default function HelloPage({ name }) {
|
|
107
|
+
return <h1>Hello, {name}!</h1>;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
LGPL-3.0-or-later
|
package/bin/noxt-server
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import minimist from 'minimist';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import { startServer } from '../index.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
9
|
+
|
|
10
|
+
// read deafault config
|
|
11
|
+
let config = yaml.load(fs.readFileSync(path.resolve(__dirname, '../default.config.yaml'), 'utf8'));
|
|
12
|
+
|
|
13
|
+
// read local config if exists
|
|
14
|
+
const configPath = 'noxt.config.yaml';
|
|
15
|
+
const configFullPath = path.resolve(process.cwd(), configPath);
|
|
16
|
+
if (fs.existsSync(configFullPath)) {
|
|
17
|
+
console.log(`Using config file: ${configFullPath}`);
|
|
18
|
+
try {
|
|
19
|
+
const localConfig = yaml.load(fs.readFileSync(configFullPath, 'utf8'));
|
|
20
|
+
Object.assign(config, localConfig);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error(`Error loading config file: ${e.message}`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
// guess some defaults based on common project structure
|
|
27
|
+
if (fs.existsSync(path.resolve(process.cwd(), 'views'))) {
|
|
28
|
+
config.views.push('views');
|
|
29
|
+
}
|
|
30
|
+
if (fs.existsSync(path.resolve(process.cwd(), 'pages'))) {
|
|
31
|
+
config.views.push('pages');
|
|
32
|
+
}
|
|
33
|
+
if (fs.existsSync(path.resolve(process.cwd(), 'templates'))) {
|
|
34
|
+
config.views.push('templates');
|
|
35
|
+
}
|
|
36
|
+
if (fs.existsSync(path.resolve(process.cwd(), 'public'))) {
|
|
37
|
+
config.static.push('public');
|
|
38
|
+
}
|
|
39
|
+
if (fs.existsSync(path.resolve(process.cwd(), 'static'))) {
|
|
40
|
+
config.static.push('static');
|
|
41
|
+
}
|
|
42
|
+
if (fs.existsSync(path.resolve(process.cwd(), 'app.js'))) {
|
|
43
|
+
config.context = 'app.js';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// override config with command line options
|
|
47
|
+
const options = minimist(process.argv.slice(2), {
|
|
48
|
+
string: ['views'], // treat as strings
|
|
49
|
+
unknown: (arg) => true
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
for (const key in options) {
|
|
53
|
+
if (key === '_') continue; // ignore non-keyed args
|
|
54
|
+
if (!(key in config)) {
|
|
55
|
+
console.warn(`Unknown config option: ${key}`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
config[key] = options[key] ?? config[key];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
startServer(config).catch(err => {
|
|
62
|
+
console.error(`Error starting server: ${err.message}`);
|
|
63
|
+
console.log(err.stack);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
});
|
package/config.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export const handlers = {
|
|
5
|
+
port: (val) => Number.isInteger(val) && val > 0 && val < 65536 ? + val : new Error('Port must be an integer between 1 and 65535'),
|
|
6
|
+
host: (val) => typeof val === 'string' && val.length > 0 ? val : new Error('Host must be a non-empty string'),
|
|
7
|
+
views: (val) => {
|
|
8
|
+
val = [].concat(val); // ensure array
|
|
9
|
+
for (const dir of val) {
|
|
10
|
+
if (typeof dir !== 'string' || dir.length === 0 || !fs.existsSync(path.resolve(process.cwd(), dir))) {
|
|
11
|
+
return new Error(`Each views directory must be a valid directory path. Invalid: ${dir}`);
|
|
12
|
+
}
|
|
13
|
+
return val;
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
static: (val) => {
|
|
17
|
+
if (!val) return val; // allow empty
|
|
18
|
+
val = [].concat(val); // ensure array
|
|
19
|
+
for (const dir of val) {
|
|
20
|
+
if (typeof dir !== 'string' || dir.length === 0 || !fs.existsSync(path.resolve(process.cwd(), dir))) {
|
|
21
|
+
return new Error(`Each static directory must be a valid directory path. Invalid: ${dir}`);
|
|
22
|
+
}
|
|
23
|
+
return val;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
logLevel: (val) => ['error', 'warn', 'info', 'debug'].includes(val) ? val : new Error('Log level must be one of: error, warn, info, debug'),
|
|
27
|
+
ssl: (val) => {
|
|
28
|
+
// either boolean false, or object with cert and key and any other options for https.createServer
|
|
29
|
+
if (val === false) return val;
|
|
30
|
+
if (typeof val === 'object' && val !== null && typeof val.cert === 'string' && val.cert.length > 0 && typeof val.key === 'string' && val.key.length > 0) {
|
|
31
|
+
// check if files exist
|
|
32
|
+
if (!fs.existsSync(path.resolve(process.cwd(), val.cert))) return new Error('SSL certificate file does not exist');
|
|
33
|
+
if (!fs.existsSync(path.resolve(process.cwd(), val.key))) return new Error('SSL key file does not exist');
|
|
34
|
+
return val;
|
|
35
|
+
}
|
|
36
|
+
return new Error('SSL must be a boolean false, or an object with cert and key');
|
|
37
|
+
},
|
|
38
|
+
logFile: (val) => (!val || typeof val === 'string' && val.length > 0) ? val : new Error('Log file must be a non-empty string'),
|
|
39
|
+
context: async (val) => {
|
|
40
|
+
// import a list of modules, and return an object with their exports
|
|
41
|
+
if (!val) return []; // allow empty
|
|
42
|
+
val = [].concat(val); // ensure array
|
|
43
|
+
for (const mod of val) {
|
|
44
|
+
console.log(`Importing context module: ${mod}`);
|
|
45
|
+
if (typeof mod !== 'string' || mod.length === 0 || !fs.existsSync(path.resolve(process.cwd(), mod))) {
|
|
46
|
+
return new Error(`Each context module must be a valid module path. Invalid: ${mod}`);
|
|
47
|
+
}
|
|
48
|
+
// import module
|
|
49
|
+
}
|
|
50
|
+
return val;
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Register a handler for a config option
|
|
55
|
+
* The handler is called with the config value, and should return the processed value
|
|
56
|
+
* or an Error object if the value is invalid
|
|
57
|
+
*
|
|
58
|
+
* @param {string} key
|
|
59
|
+
* @param {Function} handler
|
|
60
|
+
*/
|
|
61
|
+
export function registerConfigHandler(key, handler = val=>val) {
|
|
62
|
+
console.log(`Registering config handler for ${key}`);
|
|
63
|
+
handlers[key] = handler;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function processConfig(config, warn) {
|
|
67
|
+
const ret = {};
|
|
68
|
+
for (const key in config) {
|
|
69
|
+
if (handlers[key]) {
|
|
70
|
+
const result = await handlers[key](config[key]);
|
|
71
|
+
if (result instanceof Error) {
|
|
72
|
+
console.error(`Invalid config option "${key}": ${result.message}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
} else {
|
|
75
|
+
if (result !== undefined) config[key] = result;
|
|
76
|
+
}
|
|
77
|
+
} else if (warn) {
|
|
78
|
+
console.warn(`Unknown config option: ${key}`);
|
|
79
|
+
}
|
|
80
|
+
ret[key] = config[key];
|
|
81
|
+
}
|
|
82
|
+
return config;
|
|
83
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import express, { json, urlencoded } from 'express';
|
|
2
|
+
import serve_static from 'serve-static';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import cookieParser from 'cookie-parser';
|
|
5
|
+
import logger from 'morgan';
|
|
6
|
+
import noxt from 'noxt-js-middleware';
|
|
7
|
+
import { readFileSync } from 'fs';
|
|
8
|
+
import { createServer } from 'https';
|
|
9
|
+
import reloadServer from './reload_server.js';
|
|
10
|
+
import { processConfig } from './config.js';
|
|
11
|
+
export { registerConfigHandler } from './config.js';
|
|
12
|
+
export { registerPageExportHandler } from 'noxt-js-middleware';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Starts a Noxt server based on a fully resolved config object.
|
|
16
|
+
* @param {Object} config - Configuration object
|
|
17
|
+
* @param {number} config.port - Port to listen on
|
|
18
|
+
* @param {string|string[]} config.views - Path(s) to JSX components
|
|
19
|
+
* @param {Object} config.context - Context object to inject into components
|
|
20
|
+
* @param {string} [config.staticDir] - Optional static files directory
|
|
21
|
+
* @param {Object|boolean} [config.ssl] - false or { cert, key, ca }
|
|
22
|
+
* @param {string} [config.layout] - Default layout component name
|
|
23
|
+
* @returns {Promise<{app: express.Application, server: import('http').Server|import('https').Server}>}
|
|
24
|
+
*/
|
|
25
|
+
export async function startServer (config) {
|
|
26
|
+
// Load context
|
|
27
|
+
const context = {};
|
|
28
|
+
config = await processConfig(config, false);
|
|
29
|
+
for (const mod of [].concat(config.context).flat(Infinity).filter(Boolean)) {
|
|
30
|
+
Object.assign(context, await import(resolve(mod)));
|
|
31
|
+
}
|
|
32
|
+
config = context.config = await processConfig(config,true);
|
|
33
|
+
console.log('Final config:', config);
|
|
34
|
+
await context.init?.(context);
|
|
35
|
+
|
|
36
|
+
const app = express();
|
|
37
|
+
|
|
38
|
+
// Basic middleware
|
|
39
|
+
app.use(logger('dev'));
|
|
40
|
+
app.use(json());
|
|
41
|
+
app.use(urlencoded({ extended: false }));
|
|
42
|
+
app.use(cookieParser());
|
|
43
|
+
|
|
44
|
+
// Reload server for development
|
|
45
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
46
|
+
app.use(reloadServer);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Serve static files if provided
|
|
50
|
+
for (const dir of [].concat(config.static).flat(Infinity).filter(Boolean)) {
|
|
51
|
+
app.use(serve_static(resolve(dir)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
// Mount the Noxt router
|
|
57
|
+
const router = await noxt({
|
|
58
|
+
directory: config.views,
|
|
59
|
+
context: context,
|
|
60
|
+
layout: config.layout,
|
|
61
|
+
});
|
|
62
|
+
app.use(router);
|
|
63
|
+
|
|
64
|
+
// Default error handler
|
|
65
|
+
app.use((err, req, res, next) => {
|
|
66
|
+
res.status(err.status || 500);
|
|
67
|
+
res.send(err.message || 'Internal Server Error');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let server;
|
|
71
|
+
if (config.ssl && config.ssl !== false) {
|
|
72
|
+
const sslOptions = {
|
|
73
|
+
key: readFileSync(resolve(config.ssl.key)),
|
|
74
|
+
cert: readFileSync(resolve(config.ssl.cert)),
|
|
75
|
+
};
|
|
76
|
+
if (config.ssl.ca) sslOptions.ca = readFileSync(resolve(config.ssl.ca));
|
|
77
|
+
server = createServer(sslOptions, app);
|
|
78
|
+
server.listen(config.port, () => {
|
|
79
|
+
console.log(`Noxt HTTPS server running on https://localhost:${config.port}`);
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
server = app.listen(config.port, () => {
|
|
83
|
+
console.log(`Noxt HTTP server running on http://localhost:${config.port}`);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { app, server };
|
|
88
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "noxt-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Server for noxt-js-middleware with CLI and config support",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"noxt-server": "./bin/noxt-server"
|
|
9
|
+
},
|
|
10
|
+
"repository": "https://github.com/zocky/noxt-js",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "echo \"No tests yet\" && exit 0"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"express",
|
|
16
|
+
"jsx",
|
|
17
|
+
"noxt",
|
|
18
|
+
"server",
|
|
19
|
+
"ssr",
|
|
20
|
+
"async",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"author": "Zoran Obradović [https://github.com/zocky]",
|
|
24
|
+
"license": "GPL-3.0-or-later",
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"cookie-parser": "^1.4.6",
|
|
27
|
+
"express": "^5.0.0",
|
|
28
|
+
"js-yaml": "^4.1.0",
|
|
29
|
+
"minimist": "^1.2.8",
|
|
30
|
+
"morgan": "^1.10.0",
|
|
31
|
+
"noxt-js-middleware": "^1.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"cookie-parser": "^1.4.6",
|
|
35
|
+
"express": "^5.0.0",
|
|
36
|
+
"js-yaml": "^4.1.0",
|
|
37
|
+
"minimist": "^1.2.8",
|
|
38
|
+
"morgan": "^1.10.0",
|
|
39
|
+
"noxt-js-middleware": "file:../noxt-js-middleware"
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/reload_client.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const eventSource = new EventSource('/_events');
|
|
2
|
+
let reload = false;
|
|
3
|
+
eventSource.addEventListener('connected', () => {
|
|
4
|
+
if (reload) {
|
|
5
|
+
console.log('%c Reloading... ','color: #ffd; background-color: #080');
|
|
6
|
+
window.location.reload();
|
|
7
|
+
} else {
|
|
8
|
+
console.log('%c Connected ', 'color: #ffd; background-color: #080' );
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
});
|
|
12
|
+
eventSource.addEventListener('reload', () => {
|
|
13
|
+
console.log('%c Restarting... ','color: #ffd; background-color: #080');
|
|
14
|
+
reload = true;
|
|
15
|
+
});
|
package/reload_server.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
5
|
+
|
|
6
|
+
const router = express.Router();
|
|
7
|
+
export default router;
|
|
8
|
+
|
|
9
|
+
const clients = new Set();
|
|
10
|
+
router.get('/_events', async (req, res) => {
|
|
11
|
+
res.writeHead(200, {
|
|
12
|
+
'Content-Type': 'text/event-stream',
|
|
13
|
+
'Cache-Control': 'no-cache',
|
|
14
|
+
'Connection': 'keep-alive',
|
|
15
|
+
'content-encoding': 'none'
|
|
16
|
+
});
|
|
17
|
+
res.write('\nevent:connected\ndata:\n\n');
|
|
18
|
+
clients.add(res);
|
|
19
|
+
console.log('client connected');
|
|
20
|
+
res.on('close', () => {
|
|
21
|
+
console.log('client disconnected');
|
|
22
|
+
clients.delete(res);
|
|
23
|
+
res.end();
|
|
24
|
+
})
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const reloadJs = fs.readFileSync(path.join(__dirname, 'reload_client.js'), 'utf8');
|
|
28
|
+
router.get('/_reload.js', async (req, res) => {
|
|
29
|
+
res.set('Content-Type', 'text/javascript');
|
|
30
|
+
res.send(reloadJs);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
process.on('SIGUSR2', async () => {
|
|
35
|
+
console.log('beforeExit');
|
|
36
|
+
const promises = Array.from(clients).map(async client => {
|
|
37
|
+
console.log('restarting client')
|
|
38
|
+
client.write('event: reload\ndata:\n\n');
|
|
39
|
+
return new Promise(resolve => client.end(resolve));
|
|
40
|
+
});
|
|
41
|
+
await Promise.all(promises);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
});
|