noxt-server 0.1.14 → 0.1.16

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.
@@ -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() {
package/units/fetch.js CHANGED
@@ -1,27 +1,43 @@
1
1
  export const info = {
2
+ name: 'fetch',
2
3
  version: '1.0.0',
3
- description: 'Preload props from urls',
4
- requires: ['noxt-plugin','#fetch'],
4
+ description: 'Preload props from URLs',
5
+ requires: ['noxt-plugin', '#fetch'],
5
6
  }
6
7
 
7
8
  export default mlm => ({
8
- 'componentExports.fetch': async ({exported, props, ctx}) => {
9
+ 'requestContext.fetch': ({ ctx }) => {
10
+ // Wrapper that respects request cache headers
11
+ return (url, options = {}) => {
12
+ const forceFresh = ctx.req.headers.pragma === 'no-cache';
13
+ return mlm.services.fetch.fetch(url, {
14
+ ...options,
15
+ cache: forceFresh ? 'no-cache' : options.cache,
16
+ cors: true
17
+ });
18
+ };
19
+ },
20
+
21
+ 'componentExports.fetch': async ({ component, exported, props, ctx }) => {
22
+ const promises = [];
23
+ const ids = [];
24
+ const urls = [];
25
+
9
26
  for (const id in exported) {
10
- let url = await mlm.utils.eval(exported[id], props, ctx);
11
- // mlm.log('fetching', id, url);
12
27
  try {
13
- const now = performance.now();
14
- const res = await ctx.fetch(url);
15
- mlm.log('fetched', id, url, (performance.now() - now).toFixed(3) +'ms');
16
- props[id] = await res.json();
17
- //mlm.log('fetched', url, JSON.stringify(props[id]).length);
28
+ const url = await mlm.utils.eval(exported[id], props, ctx);
29
+ ids.push(id);
30
+ urls.push(url);
31
+ promises.push(ctx.fetch(url).then(res => res.json()));
18
32
  } catch (e) {
19
- if (process.env.NODE_ENV !== 'production') {
20
- mlm.throw(new Error('error fetching ' + url + ': ' + e));
21
- }
22
- mlm.error('fetch', url, e);
23
33
  props[id] = null;
24
34
  }
25
35
  }
36
+
37
+ const results = await Promise.allSettled(promises);
38
+
39
+ results.forEach((result, index) => {
40
+ props[ids[index]] = result.status === 'fulfilled' ? result.value : null;
41
+ });
26
42
  },
27
- })
43
+ });
package/units/hooks.js CHANGED
@@ -2,7 +2,7 @@ export const info = {
2
2
  name: 'hooks',
3
3
  version: '1.0.0',
4
4
  description: 'Hooks',
5
- requires: ['utils','services']
5
+ requires: ['utils', 'services']
6
6
  }
7
7
  const hooks = {}
8
8
  const hook_handlers = {}
@@ -10,18 +10,31 @@ const hook_handlers = {}
10
10
  export default mlm => {
11
11
  return ({
12
12
  'define.hooks': () => mlm.utils.readOnly(hooks, 'hooks'),
13
- 'register.hooks': mlm.utils.collector(hooks, {
14
- is: 'function',
13
+ 'collect.hooks': ({
14
+ target: hooks,
15
+ is: 'function|boolean',
15
16
  mode: 'object',
16
- map: (fn, key) => (...args) => {
17
- for (const handler of hook_handlers[key] ?? []) {
18
- fn(handler, ...args);
17
+ map: (fn, key) => {
18
+ if (fn === false) return () => { };
19
+ if (fn === true) return async (...args) => {
20
+ for (const handler of hook_handlers[key] ?? []) {
21
+ await handler(...args);
22
+ }
23
+ }
24
+ if (mlm.is.function(fn)) {
25
+ return async (...args) => {
26
+ for (const handler of hook_handlers[key] ?? []) {
27
+ await fn(handler, ...args);
28
+ }
29
+ }
19
30
  }
31
+ mlm.throw('Cosmic ray: Invalid hook');
20
32
  }
21
33
  }),
22
- 'register.on': mlm.utils.collector(hook_handlers, {
34
+ 'collect.on': {
35
+ target: hook_handlers,
23
36
  is: 'function',
24
- mode: 'array'
25
- })
37
+ mode: 'arrays'
38
+ }
26
39
  })
27
40
  }
@@ -0,0 +1,263 @@
1
+ export const info = {
2
+ name: 'image-resizer',
3
+ description: 'Image resizing and caching service',
4
+ requires: ['express'],
5
+ provides: ['#image-resizer'],
6
+ packages: ['sharp','express']
7
+ }
8
+
9
+ import crypto from 'crypto';
10
+ import fs from 'fs/promises';
11
+ import path from 'path';
12
+
13
+ // Pure helper functions (don't need mlm)
14
+ function generateHash(options, maxLength = 16) {
15
+ const str = JSON.stringify(options);
16
+ const fullHash = crypto.createHash('sha256').update(str).digest('base64url');
17
+ return fullHash.substring(0, maxLength);
18
+ }
19
+
20
+ function normalizeFormat(format) {
21
+ if (!format) return 'jpeg';
22
+ if (format === 'jpg') return 'jpeg';
23
+ return format;
24
+ }
25
+
26
+ function getShardedPath(hash, ext, depth, segmentLength) {
27
+ const segments = [];
28
+ for (let i = 0; i < depth; i++) {
29
+ segments.push(hash.substring(i * segmentLength, (i + 1) * segmentLength));
30
+ }
31
+ segments.push(`${hash}.${ext}`);
32
+ return segments.join('/');
33
+ }
34
+
35
+ export default async mlm => {
36
+ const {default:sharp} = mlm.packages.sharp;
37
+
38
+ async function getMetadata(hash) {
39
+ const shardPath = getShardedPath(hash, 'json', mlm.config.imageResizer.shardDepth, mlm.config.imageResizer.shardSegmentLength);
40
+ const metaPath = path.join(path.resolve(mlm.config.imageResizer.storage), shardPath);
41
+ try {
42
+ const data = await fs.readFile(metaPath, 'utf8');
43
+ return JSON.parse(data);
44
+ } catch (err) {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ async function saveMetadata(hash, metadata) {
50
+ const shardPath = getShardedPath(hash, 'json', mlm.config.imageResizer.shardDepth, mlm.config.imageResizer.shardSegmentLength);
51
+ const metaPath = path.join(path.resolve(mlm.config.imageResizer.storage), shardPath);
52
+ await fs.mkdir(path.dirname(metaPath), { recursive: true });
53
+ await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
54
+ }
55
+
56
+ async function processImage(hash, metadata) {
57
+ const format = metadata.format;
58
+ const ext = format === 'jpeg' ? 'jpg' : format;
59
+ const shardPath = getShardedPath(hash, ext, mlm.config.imageResizer.shardDepth, mlm.config.imageResizer.shardSegmentLength);
60
+ const filePath = path.join(path.resolve(mlm.config.imageResizer.storage), shardPath);
61
+
62
+ // Ensure directory exists
63
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
64
+
65
+ // Download image
66
+ const response = await fetch(metadata.url);
67
+ if (!response.ok) {
68
+ throw new Error(`Failed to fetch image: ${response.statusText} ${metadata.url}`);
69
+ }
70
+
71
+ const buffer = await response.arrayBuffer();
72
+ const imageBuffer = Buffer.from(buffer);
73
+
74
+ // Process with sharp
75
+ let image = sharp(imageBuffer);
76
+ const options = metadata.options;
77
+
78
+ // Resize if dimensions provided
79
+ if (options.width || options.height) {
80
+ const resizeOptions = {
81
+ width: +options.width || null,
82
+ height: +options.height || null,
83
+ fit: options.fit || 'inside',
84
+ };
85
+
86
+ if (options.background) {
87
+ resizeOptions.background = options.background;
88
+ }
89
+ image = image.resize(resizeOptions);
90
+ }
91
+
92
+ // Set format and quality
93
+ switch (format) {
94
+ case 'jpeg':
95
+ image = image.jpeg({ quality: options.quality });
96
+ break;
97
+ case 'png':
98
+ image = image.png();
99
+ break;
100
+ case 'webp':
101
+ image = image.webp({ quality: options.quality });
102
+ break;
103
+ case 'avif':
104
+ image = image.avif({ quality: options.quality });
105
+ break;
106
+ }
107
+
108
+ // Process and save
109
+ const processedBuffer = await image.toBuffer();
110
+ await fs.writeFile(filePath, processedBuffer);
111
+
112
+ // Update metadata
113
+ metadata.lastProcessed = new Date().toISOString();
114
+ await saveMetadata(hash, metadata);
115
+
116
+ return processedBuffer;
117
+ }
118
+
119
+ async function resizeImage(url, options = {}) {
120
+ if (!url) {
121
+ throw new Error('URL is required');
122
+ }
123
+
124
+ // Normalize format
125
+ const format = normalizeFormat(options.format);
126
+
127
+ // Merge with defaults
128
+ const opts = {
129
+ url,
130
+ width: options.width || null,
131
+ height: options.height || null,
132
+ fit: options.fit || 'cover',
133
+ format,
134
+ quality: options.quality || 80,
135
+ ...options
136
+ };
137
+
138
+ const hash = generateHash(opts, mlm.config.imageResizer.maxHashLength);
139
+
140
+ // Save metadata
141
+ const metadata = {
142
+ url,
143
+ format,
144
+ options: opts,
145
+ created: new Date().toISOString(),
146
+ hash,
147
+ };
148
+
149
+ await saveMetadata(hash, metadata);
150
+
151
+ // Return URL with extension and sharded path
152
+ const ext = format === 'jpeg' ? 'jpg' : format;
153
+ const shardPath = getShardedPath(hash, ext, mlm.config.imageResizer.shardDepth, mlm.config.imageResizer.shardSegmentLength);
154
+ return `${mlm.config.imageResizer.route}/${shardPath}`;
155
+ }
156
+
157
+ async function refreshImage(hash) {
158
+ const metadata = await getMetadata(hash);
159
+ if (!metadata) {
160
+ throw new Error('Image not found');
161
+ }
162
+ return processImage(hash, metadata);
163
+ }
164
+
165
+ return {
166
+ 'config.imageResizer': {
167
+ is: {
168
+ route: 'string',
169
+ storage: 'string',
170
+ timeout: 'positiveInteger|none',
171
+ maxFileSize: 'positiveInteger|none',
172
+ maxHashLength: 'positiveInteger',
173
+ shardDepth: 'positiveInteger',
174
+ shardSegmentLength: 'positiveInteger',
175
+ },
176
+ default: {
177
+ route: '/_images',
178
+ storage: '.cache/images',
179
+ timeout: 10000,
180
+ maxFileSize: 10 * 1024 * 1024, // 10MB
181
+ maxHashLength: 16,
182
+ shardDepth: 2,
183
+ shardSegmentLength: 2,
184
+ }
185
+ },
186
+
187
+ 'services.imageResizer': () => ({
188
+ resize: (url, options = {}) => resizeImage(url, options),
189
+ getInfo: (hash) => getMetadata(hash),
190
+ refresh: (hash) => refreshImage(hash),
191
+ }),
192
+
193
+ 'middleware.imageResizerServe': async () => {
194
+ const { default: express } = mlm.packages.express;
195
+ const router = express.Router();
196
+
197
+ // Static middleware runs FIRST, mounted at the configured route
198
+ router.use(mlm.config.imageResizer.route, express.static(path.resolve(mlm.config.imageResizer.storage), {
199
+ maxAge: '1y',
200
+ immutable: true,
201
+ setHeaders: res => res.logGroup = 'static',
202
+ }));
203
+
204
+ return (req,res,next) => {
205
+ if (req.bustCache) return next();
206
+ router(req, res, next);
207
+ }
208
+ },
209
+
210
+ 'middleware.imageResizerProcess': async () => {
211
+ const router = mlm.packages.express.Router();
212
+
213
+ // This runs SECOND, only if static didn't find the file
214
+ router.get(`${mlm.config.imageResizer.route}/*path`, async (req, res, next) => {
215
+ res.logGroup = 'image-resizer';
216
+ try {
217
+ const fullPath = req.params.path.join('/');
218
+
219
+ // Extract hash from path (remove extension and any directory segments)
220
+ const filename = path.basename(fullPath);
221
+ const hash = filename.replace(/\.[^.]+$/, '');
222
+
223
+ const metadata = await getMetadata(hash);
224
+ if (!metadata) {
225
+ return res.status(404).send('Image not found');
226
+ }
227
+
228
+
229
+ // Process and serve the image
230
+ try {
231
+ const imageBuffer = await processImage(hash, metadata);
232
+
233
+ // Set content type
234
+ const mimeTypes = {
235
+ 'jpeg': 'image/jpeg',
236
+ 'png': 'image/png',
237
+ 'webp': 'image/webp',
238
+ 'avif': 'image/avif',
239
+ };
240
+
241
+ res.set('Content-Type', mimeTypes[metadata.format] || 'image/jpeg');
242
+ res.set('Cache-Control', 'public, max-age=31536000, immutable');
243
+ res.send(imageBuffer);
244
+ } catch (err) {
245
+ mlm.log('Image processing failed:', err.message);
246
+ return res.status(502).send('Failed to process image');
247
+ }
248
+ } catch (err) {
249
+ next(err);
250
+ }
251
+ });
252
+
253
+ return router;
254
+ },
255
+
256
+ async onStart() {
257
+ // Ensure storage directory exists
258
+ await fs.mkdir(path.resolve(mlm.config.imageResizer.storage), { recursive: true });
259
+
260
+ // Import dependencies
261
+ }
262
+ };
263
+ };
package/units/logger.js CHANGED
@@ -1,15 +1,66 @@
1
1
  export const info = {
2
2
  name: 'logger',
3
- version: '1.0.0',
4
- description: 'Minimal logger',
3
+ version: '1.0.1',
4
+ description: 'Noxt logger',
5
5
  requires: ['express'],
6
6
  }
7
7
 
8
8
  export default mlm => ({
9
- 'middleware.logger': async (app) => {
10
- return (req, res, next) => {
11
- mlm.log(req.method, req.url);
12
- next();
13
- }
9
+ 'config.logger': {
10
+ is: {
11
+ level: 'string',
12
+ format: 'string',
13
+ exclude: { $any: [['string'], 'string', 'null'] },
14
+ },
15
+ default: {
16
+ level: 'info',
17
+ format: 'combined',
18
+ exclude: null
19
+ },
20
+
21
+ },
22
+ 'middleware.logger': async () => {
23
+ let { level, format, exclude } = mlm.config.logger;
24
+ exclude = exclude
25
+ ? [].concat(exclude)
26
+ : null;
27
+
28
+ return (req, res, next) => {
29
+ const start = process.hrtime.bigint();
30
+ const time = new Date();
31
+ const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.socket.remoteAddress;
32
+ const ua = req.headers['user-agent'] || '-';
33
+
34
+ const _send = res.send.bind(res);
35
+ res.send = (body) => {
36
+ try {
37
+ res._realBodySize = Buffer.byteLength(body);
38
+ } catch (e) {
39
+ res._realBodySize = 0;
40
+ };
41
+ _send(body);
42
+ }
43
+
44
+ res.on('finish', () => {
45
+
46
+ const used = process.memoryUsage();
47
+ const memory = used.heapUsed / 1024 / 1024;
48
+ const dur = Number(process.hrtime.bigint() - start) / 1e6; // ms
49
+ if (exclude && exclude.includes(res.logGroup)) return;
50
+ const parts = [
51
+ time.toISOString().slice(0, 19).replace('T', ' '),
52
+ memory.toFixed(0).padStart(3, ' ') + 'M',
53
+ ip,
54
+ dur.toFixed(0).padStart(4, ' ') + 'ms',
55
+ (res._realBodySize / 1024).toFixed(0).padStart(4, ' ') + 'K',
56
+ res.statusCode,
57
+ req.method + ' ' +
58
+ (req.originalUrl || req.url),
59
+ //ua
60
+ ]
61
+ mlm.log(parts.join(' | '));
62
+ });
63
+ next();
64
+ }
14
65
  },
15
- })
66
+ });
package/units/noxt-dev.js CHANGED
@@ -4,10 +4,12 @@ export const info = {
4
4
  requires: [
5
5
  'express',
6
6
  'logger',
7
- 'static',
8
- 'noxt-router-dev',
7
+ 'bundler',
9
8
  'reload',
9
+ 'compression',
10
+ 'static',
10
11
  'fetch-cache-fs',
12
+ 'noxt-router',
11
13
  'fetch',
12
14
  ],
13
15
  }