noxt-server 0.1.13 → 0.1.15

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/noxt-server.js CHANGED
@@ -5,31 +5,56 @@ import MLM from 'mlm-core';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = dirname(__filename);
7
7
 
8
- export async function startServer({ config, recipe }) {
8
+ function resolveModule(name) {
9
+ if (!name.includes('/')) {
10
+ return `${__dirname}/units/${name}.js`;
11
+ }
12
+ if (name.startsWith('app/')) {
13
+ return `${process.cwd()}/units/${name.slice(4)}.js`;
14
+ }
15
+ if (name.startsWith('contrib/')) {
16
+ const m = name.match(/^contrib\/([^/]+)(?:\/(.*))?$/);
17
+ if (!m) throw new Error(`Invalid contrib module name: ${name}`);
18
+ if (!m[2]) return `noxt-contrib-${m[1]}`;
19
+ return `noxt-contrib-${m[1]}/${m[2]}`;
20
+ }
21
+ throw new Error(`Invalid module name: ${name}`);
22
+ }
23
+
24
+ export async function startServer({ config, recipe, import: _import}) {
9
25
 
10
- const mlmInstance = MLM({
11
- import:
12
- p => import(p),
13
- resolveModule:
14
- name => name.startsWith('app/')
15
- ? `${process.cwd()}/units/${name.slice(4)}.js`
16
- : `${__dirname}/units/${name}.js`
26
+ const beforeBoot = process.hrtime.bigint();
27
+ const mlmInstance = await MLM({
28
+ import: _import ?? (p => import(p)),
29
+ resolveModule
17
30
  });
18
31
  try {
19
-
32
+ const afterBoot = process.hrtime.bigint();
20
33
  const report = await mlmInstance.analyze(recipe ?? 'noxt-dev');
21
34
  if (!report.success) {
22
35
  console.log('Bad recipe: ' + recipe + '\n' + report.order.join(', '));
23
36
  console.log(report.errors.join('\n'));
24
37
  process.exit(1);
25
38
  }
26
-
39
+ const afterAnalyze = process.hrtime.bigint();
27
40
  await mlmInstance.install(recipe ?? 'noxt-dev');
41
+ const afterInstall = process.hrtime.bigint();
28
42
  const mlm = mlmInstance.context;
29
43
  await mlm.services.config.merge(config);
30
- console.log(mlm.config);
44
+ const afterConfig = process.hrtime.bigint();
31
45
  await mlmInstance.start();
46
+ const afterStart = process.hrtime.bigint();
47
+
48
+ console.log(
49
+ '[noxt-server] Total time', Number((afterStart - beforeBoot) / 1_000_000n), 'ms',
50
+ '| Boot', Number((afterBoot - beforeBoot) / 1_000_000n), 'ms',
51
+ '| Load', Number((afterAnalyze - afterBoot) / 1_000_000n), 'ms',
52
+ '| Install', Number((afterInstall - afterAnalyze) / 1_000_000n), 'ms',
53
+ '| Config', Number((afterConfig - afterInstall) / 1_000_000n), 'ms',
54
+ '| Start', Number((afterStart - afterConfig) / 1_000_000n), 'ms',
55
+ );
32
56
 
57
+ mlmInstance.repl();
33
58
  } catch (e) {
34
59
  console.log(await mlmInstance.analyze(recipe ?? 'noxt-dev'));
35
60
  console.error(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noxt-server",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Server for noxt-js-middleware with CLI and config support",
5
5
  "type": "module",
6
6
  "main": "noxt-server.js",
@@ -23,13 +23,15 @@
23
23
  "author": "Zoran Obradović [https://github.com/zocky]",
24
24
  "license": "GPL-3.0-or-later",
25
25
  "dependencies": {
26
- "noxt-js-middleware": "^1.0.5",
27
- "mlm-core": "^1.0.5",
28
- "express": "^5.0.0",
26
+ "noxt-js-middleware": "^1.0.7",
27
+ "mlm-core": "^1.0.7",
28
+ "express": "^5.1.0",
29
29
  "cookie-parser": "^1.4.6",
30
- "js-yaml": "^4.1.0",
31
- "minimist": "^1.2.8",
32
- "node-fetch-cache": "^5.0.2"
30
+ "js-yaml": "^4.1.0"
31
+ },
32
+ "peerDependencies": {
33
+ "compression": "^1.8.1",
34
+ "sharp": "^0.34.5"
33
35
  },
34
36
  "devDependencies": {
35
37
  "noxt-js-middleware": "../noxt-js-middleware",
@@ -0,0 +1,46 @@
1
+ export const info = {
2
+ name: 'bundler',
3
+ version: '1.0.0',
4
+ description: 'Bundle js and css from component exports',
5
+ requires: ['noxt-plugin'],
6
+ }
7
+
8
+ export default mlm => {
9
+ const styles = {};
10
+ const scripts = {};
11
+ return {
12
+ 'config.bundler': {
13
+ is: {
14
+ style: 'string|false',
15
+ script: 'string|false',
16
+ },
17
+ default: {
18
+ style: '_style',
19
+ script: '_script',
20
+ },
21
+ },
22
+ 'services.bundler': () => ({ styles, scripts }),
23
+ 'registerComponent.bundler': async ({ name, component, module }) => {
24
+ styles[component.name] = module.style ? `/* ${name} */ \n ${module.style}\n` : '';
25
+ scripts[component.name] = module.script ? `/* ${name} */ \n ${module.script}\n` : '';
26
+ },
27
+ 'middleware.bundler': () => {
28
+ const router = mlm.services.express.Router();
29
+ if (mlm.config.bundler.style) {
30
+ router.get(`/${mlm.config.bundler.style}`, (req, res) => {
31
+ res.logGroup = 'bundler';
32
+ res.set('Content-Type', 'text/css');
33
+ res.send( Object.values(styles).join('') );
34
+ });
35
+ }
36
+ if (mlm.config.bundler.script) {
37
+ router.get(`/${mlm.config.bundler.script}`, (req, res) => {
38
+ res.logGroup = 'bundler';
39
+ res.set('Content-Type', 'text/javascript');
40
+ res.send( Object.values(scripts).join('') );
41
+ });
42
+ }
43
+ return router
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,33 @@
1
+ // units/compression.js
2
+ export const info = {
3
+ name: 'compression',
4
+ version: '1.0.0',
5
+ description: 'Response compression',
6
+ requires: ['express'],
7
+ npm: {
8
+ 'compression': '^1.8.0'
9
+ },
10
+
11
+ }
12
+
13
+ export default mlm => ({
14
+ 'config.compression': {
15
+ is: {
16
+ enabled: 'boolean',
17
+ level: 'number', // 0-9, default 6
18
+ threshold: 'number', // bytes, default 1024
19
+ },
20
+ default: {
21
+ enabled: true,
22
+ level: 6,
23
+ threshold: 1024, // only compress responses > 1kb
24
+ },
25
+ },
26
+
27
+ 'middleware.compression': async () => {
28
+ const { enabled, level, threshold } = mlm.config.compression;
29
+ if (!enabled) return null;
30
+ const compression = (await mlm.import('compression')).default;
31
+ return compression({ level, threshold });
32
+ },
33
+ });
package/units/config.js CHANGED
@@ -5,6 +5,7 @@ export const info = {
5
5
  requires: ['utils','services'],
6
6
  }
7
7
 
8
+ const config = {};
8
9
  const config_is = {};
9
10
  const config_defaults = {};
10
11
  const config_defs = {}
@@ -54,7 +55,11 @@ function isPlainObject(value) {
54
55
  }
55
56
 
56
57
  export default mlm => ({
57
- 'define.config': () => ({}),
58
+ 'define.config': {
59
+ get: () => config,
60
+ enumerable: true,
61
+ configurable: false
62
+ },
58
63
  'register.config': (defs, unit) => {
59
64
  for (const key in defs) {
60
65
  const def = defs[key];
@@ -78,8 +83,8 @@ export default mlm => ({
78
83
  },
79
84
  'utils.merge_deep': merge_deep,
80
85
  'services.config': () => new class ConfigService {
81
- process(config) {
82
- const merged = merge_deep({}, config_defaults, config);
86
+ process(userConfig) {
87
+ const merged = merge_deep({}, config_defaults, userConfig);
83
88
  const ret = {};
84
89
  for (const key in config_defs) {
85
90
  const { is: type, normalize } = config_defs[key];
@@ -94,10 +99,10 @@ export default mlm => ({
94
99
  }
95
100
  return ret;
96
101
  }
97
- merge(config) {
98
- mlm.log('config', config,mlm.config);
99
- Object.assign(mlm.config, this.process(config));
100
-
102
+ merge(userConfig) {
103
+ // console.log('initial',config_defaults,userConfig)
104
+ Object.assign(config, this.process(userConfig));
105
+ // console.log(userConfig,'final',config)
101
106
  }
102
107
  get_defs() {
103
108
  return {
package/units/env.js CHANGED
@@ -3,12 +3,12 @@ export const info = {
3
3
  version: '1.0.0',
4
4
  description: 'Environment',
5
5
  }
6
- /*
6
+
7
7
  export default mlm => ({
8
8
  'define.DEV': () => process.env.NODE_ENV !== 'production',
9
9
  'define.PROD': () => process.env.NODE_ENV === 'production',
10
10
  'onBeforeLoad': () => {
11
- //process.env.NODE_ENV ||= 'development'
11
+ process.env.NODE_ENV ||= 'development'
12
12
  }
13
13
  })
14
- */
14
+
package/units/express.js CHANGED
@@ -8,54 +8,92 @@ export const info = {
8
8
  },
9
9
  }
10
10
 
11
- let app;
12
- const middlewareNames = new Set();
13
- const middlewares = [];
14
- export default mlm => ({
15
- 'define.express': { get: () => app },
16
- 'register.middleware': (conf, unit) => {
17
- for (const key in conf) {
18
- mlm.assert.not(key in middlewareNames, 'Duplicate middleware ' + key);
19
- middlewareNames.add(key);
20
- let c = conf[key];
21
- if (mlm.is.function(c)) {
22
- c = { create: c };
11
+ export default async mlm => {
12
+ let app;
13
+ const middlewareNames = new Set();
14
+ const middlewares = [];
15
+ const { default: express } = await mlm.import('express');
16
+ return {
17
+ 'services.express': () => new class {
18
+ get app () {
19
+ return app;
23
20
  }
24
- mlm.assert.is({
25
- path: 'string|none',
26
- create: 'function'
27
- }, c, 'middleware');
28
- mlm.assert.is.function(c.create, 'middleware');
29
- middlewares.push({ ...c, unit: unit.name });
30
- }
31
- },
32
- 'config.port': {
33
- is: v => Number.isInteger(v) && v > 0 && v < 65536,
34
- default: 3000
35
- },
36
- 'config.host': {
37
- is: 'string',
38
- default: 'localhost'
39
- },
40
- 'middleware.json': async (app) => {
41
- const { default: express } = await mlm.import('express');
42
- return express.json();
43
- },
44
- 'middleware.urlencoded': async (app) => {
45
- const { default: express } = await mlm.import('express');
46
- return express.urlencoded({ extended: true });
47
- },
48
- async onStart() {
49
- const { default: express } = await mlm.import('express');
50
- app = express();
51
- for (const middleware of middlewares) {
52
- const mw = await middleware.create(app);
53
- if (middleware.path) {
54
- app.use(middleware.path, mw);
55
- } else {
56
- app.use(mw);
21
+ get Router() {
22
+ return express.Router;
57
23
  }
24
+ get express () {
25
+ return express;
26
+ }
27
+ },
28
+ 'register.middleware': (conf, unit) => {
29
+ for (const key in conf) {
30
+ mlm.assert.not(key in middlewareNames, 'Duplicate middleware ' + key);
31
+ middlewareNames.add(key);
32
+ let c = conf[key];
33
+ if (mlm.is.function(c)) {
34
+ c = { create: c };
35
+ }
36
+ mlm.assert.is({
37
+ path: 'string|none',
38
+ create: 'function'
39
+ }, c, 'middleware');
40
+ mlm.assert.is.function(c.create, 'middleware');
41
+ middlewares.push({ ...c, unit: unit.name });
42
+ }
43
+ },
44
+ 'config.port': {
45
+ is: v => Number.isInteger(v) && v > 0 && v < 65536,
46
+ default: 3000
47
+ },
48
+ 'config.host': {
49
+ is: 'string',
50
+ default: 'localhost'
51
+ },
52
+ 'middleware.json': async (app) => {
53
+ return express.json();
54
+ },
55
+ 'middleware.urlencoded': async (app) => {
56
+ return express.urlencoded({ extended: true });
57
+ },
58
+ async onStart() {
59
+ app = express();
60
+ for (const middleware of middlewares) {
61
+ const mw = await middleware.create(app);
62
+ if (!mw) {
63
+ continue;
64
+ }
65
+ if (middleware.path) {
66
+ app.use(middleware.path, mw);
67
+ } else {
68
+ app.use(mw);
69
+ }
70
+ }
71
+ app.use('/.well-known', (req, res, next) => {
72
+ res.logGroup = 'well-known';
73
+ res.status(404).send('Not found');
74
+ res.end();
75
+ });
76
+ app.use((req, res, next) => {
77
+ res.logGroup = '404';
78
+ res.status(404).send('Not found');
79
+ })
80
+ app.use((error, req, res, next,) => {
81
+ mlm.log('error', error);
82
+ res.status(500).send(error.stack);
83
+ })
84
+ const server = app.listen(mlm.config.port, mlm.config.host, (error) => {
85
+ if (error) {
86
+ mlm.throw(error);
87
+ }
88
+ mlm.log(`Listening on ${mlm.config.host}:${mlm.config.port}`);
89
+ });
90
+ server.on('close', () => {
91
+ mlm.log('Server closed');
92
+ });
93
+ app.on('error', (error) => {
94
+ mlm.error(error);
95
+ app.throw(error);
96
+ });
58
97
  }
59
- app.listen(mlm.config.port, mlm.config.host);
60
- },
61
- })
98
+ }
99
+ }
@@ -1,54 +1,181 @@
1
1
  export const info = {
2
2
  name: 'fetch-cache-fs',
3
3
  version: '1.0.0',
4
- description: 'Cached fetch with filesystem cache',
5
- requires: ['noxt-plugin'],
6
4
  provides: ['#fetch'],
7
- npm: {
8
- 'node-fetch-cache': '^1.0.0',
9
- },
5
+ description: 'Cached fetch with stale-while-revalidate',
10
6
  }
11
7
 
12
- let fetchCache = null;
8
+ import { createHash } from 'crypto';
9
+ import { readFile, writeFile, mkdir, stat } from 'fs/promises';
10
+ import { join } from 'path';
13
11
 
14
- export default mlm => ({
15
- 'config.fetch': {
16
- is: {
17
- cacheDir: 'string',
18
- ttl: 'integer|none',
19
- enabled: 'boolean',
20
- retry: 'positiveInteger',
21
- timeout: 'positiveInteger'
22
- },
23
- default: {
24
- enabled: true,
25
- cacheDir: '.cache/fetch-cache',
26
- ttl: 60 * 60 * 1000,
27
- retry: 2,
28
- timeout: 5000
29
- },
30
- },
31
- 'pageContext.fetch': ({ ctx }) => globalFetch.bind(null, ctx),
32
- 'serverContext.fetch': () => globalFetch,
33
- 'onStart': async () => {
34
- const { default: nodeFetchCache, FileSystemCache } = await mlm.import('node-fetch-cache');
35
- const cfg = mlm.config.fetch;
36
- fetchCache = nodeFetchCache.create({
37
- cache: cfg.enabled ? new FileSystemCache({ cacheDirectory: cfg.cacheDir }) : undefined,
38
- ttl: cfg.ttl,
39
- retry: cfg.retry,
40
- timeout: cfg.timeout
41
- });
12
+ export default mlm => {
13
+ const inflight = new Map();
14
+ const refreshing = new Map();
15
+
16
+ async function cachedFetch(url, options = {}) {
17
+ const forceFresh = options.cache === 'no-cache';
18
+ const isGet = !options.method || options.method === 'GET';
19
+
20
+ if (!isGet) return fetchWithRetry(url, options);
21
+
22
+ const cacheKey = createHash('sha256').update(url).digest('hex');
23
+ const cachePath = join(mlm.config.fetch.cacheDir, cacheKey + '.json');
24
+
25
+ if (inflight.has(cacheKey)) {
26
+ return inflight.get(cacheKey);
27
+ }
28
+
29
+ if (!forceFresh) {
30
+ const cached = await readCache(cachePath);
31
+ if (cached) {
32
+ const age = Date.now() - cached.cachedAt;
33
+
34
+ if (age <= mlm.config.fetch.stale) {
35
+ return toResponse(cached);
36
+ }
37
+
38
+ if (age <= mlm.config.fetch.ttl) {
39
+ refreshInBackground(url, cachePath, options, cacheKey);
40
+ return toResponse(cached);
41
+ }
42
+ }
43
+ }
44
+
45
+ const promise = (async () => {
46
+ try {
47
+ const res = await fetchWithRetry(url, options);
48
+
49
+ if (res.ok) {
50
+ const body = await res.text();
51
+ await writeCache(cachePath, res, body);
52
+ return new Response(body, { status: res.status, headers: res.headers });
53
+ }
54
+ } catch (error) {
55
+ // Fetch failed completely - fall through to stale cache check
56
+ mlm.log('fetch failed, checking for stale cache:', error.message);
57
+ }
58
+
59
+ // Fallback to stale cache on error or non-ok response
60
+ const cached = await readCache(cachePath);
61
+ if (cached && Date.now() - cached.cachedAt <= mlm.config.fetch.ttl) {
62
+ mlm.log('returning stale cache for', url);
63
+ return toResponse(cached);
64
+ }
65
+
66
+ // No cache available, throw or return error response
67
+ throw new Error(`Failed to fetch ${url} and no cache available`);
68
+ })();
69
+
70
+ inflight.set(cacheKey, promise);
71
+
72
+ try {
73
+ return await promise;
74
+ } finally {
75
+ inflight.delete(cacheKey);
76
+ }
77
+ }
78
+
79
+ async function refreshInBackground(url, cachePath, options, cacheKey) {
80
+ if (refreshing.has(cacheKey)) {
81
+ return;
82
+ }
83
+
84
+ const promise = (async () => {
85
+ try {
86
+ const res = await fetchWithRetry(url, options);
87
+ if (res.ok) {
88
+ const body = await res.text();
89
+ await writeCache(cachePath, res, body);
90
+ }
91
+ } catch {}
92
+ })();
93
+
94
+ refreshing.set(cacheKey, promise);
95
+
96
+ try {
97
+ await promise;
98
+ } finally {
99
+ refreshing.delete(cacheKey);
100
+ }
101
+ }
102
+
103
+ async function fetchWithRetry(url, options) {
104
+ let lastError;
105
+
106
+ for (let i = 0; i <= mlm.config.fetch.retry; i++) {
107
+ const controller = new AbortController();
108
+ const timeout = setTimeout(() => controller.abort(), mlm.config.fetch.timeout);
109
+
110
+ try {
111
+ mlm.log('fetching', url);
112
+ const res = await fetch(url, { ...options, signal: controller.signal });
113
+ clearTimeout(timeout);
114
+ return res;
115
+ } catch (e) {
116
+ mlm.log('failed',url,e.message)
117
+ clearTimeout(timeout);
118
+ lastError = e;
119
+ if (i < mlm.config.fetch.retry) {
120
+ await new Promise(r => setTimeout(r, 100 * (i + 1)));
121
+ }
122
+ }
123
+ }
124
+
125
+ throw lastError;
126
+ }
127
+
128
+ async function readCache(path) {
129
+ try {
130
+ const stats = await stat(path);
131
+ const data = JSON.parse(await readFile(path, 'utf8'));
132
+ data.cachedAt = stats.mtimeMs;
133
+ return data;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ async function writeCache(path, res, body) {
140
+ await writeFile(path, JSON.stringify({
141
+ url: res.url,
142
+ status: res.status,
143
+ headers: Object.fromEntries(res.headers),
144
+ body,
145
+ }));
42
146
  }
43
- });
44
-
45
- async function globalFetch(ctx, url, options = {}) {
46
- let res = await fetchCache(url, options);
47
- if (ctx.req.headers.pragma === 'no-cache' && !res.isCacheMiss) {
48
- console.log('reload', url);
49
- res.ejectFromCache();
50
- res = await fetch(url, options);
147
+
148
+ function toResponse(cached) {
149
+ return new Response(cached.body, {
150
+ status: cached.status,
151
+ headers: new Headers(cached.headers),
152
+ });
51
153
  }
52
- if (!res.ok) throw new Error(res.statusText);
53
- return res;
54
- }
154
+
155
+ return {
156
+ 'config.fetch': {
157
+ is: {
158
+ cacheDir: 'string',
159
+ ttl: 'integer',
160
+ stale: 'integer',
161
+ retry: 'positiveInteger',
162
+ timeout: 'positiveInteger'
163
+ },
164
+ default: {
165
+ cacheDir: '.cache/fetch',
166
+ ttl: 24 * 60 * 60 * 1000,
167
+ stale: 60 * 60 * 1000,
168
+ retry: 2,
169
+ timeout: 5000
170
+ }
171
+ },
172
+
173
+ 'services.fetch': () => ({
174
+ fetch: cachedFetch,
175
+ }),
176
+
177
+ async onStart() {
178
+ await mkdir(mlm.config.fetch.cacheDir, { recursive: true });
179
+ }
180
+ };
181
+ };
@@ -25,7 +25,7 @@ export default mlm => ({
25
25
  }
26
26
  },
27
27
 
28
- 'pageContext.fetch': (props, ctx) => globalFetch.bind(null, ctx),
28
+ 'requestContext.fetch': (props, ctx) => globalFetch.bind(null, ctx),
29
29
  'serverContext.fetch': () => globalFetch,
30
30
 
31
31
  async onStart() {
@@ -11,19 +11,6 @@ export default mlm => ({
11
11
  requires: ['noxt-plugin'],
12
12
  provides: ['#fetch'],
13
13
  description: 'Native fetch',
14
- 'config.fetch': {
15
- is: {
16
- ttl: 'integer|none',
17
- retry: 'positiveInteger|none',
18
- timeout: 'positiveInteger|none'
19
- },
20
- default: {
21
- ttl: 60 * 60 * 1000,
22
- retry: 2,
23
- timeout: 5000
24
- }
25
- },
26
-
27
14
  'serverContext.fetch': () => fetchOrThrow,
28
15
  });
29
16