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.
- package/README.md +3 -116
- package/noxt-server.js +37 -13
- package/package.json +8 -10
- package/units/bundler.js +56 -0
- package/units/bust-cache.js +30 -0
- package/units/compression.js +30 -0
- package/units/config.js +2 -2
- package/units/env.js +3 -2
- package/units/express.js +87 -54
- package/units/fetch-cache-fs.js +172 -45
- package/units/fetch-cache.js +1 -1
- package/units/fetch.js +31 -15
- package/units/hooks.js +22 -9
- package/units/image-resizer.js +263 -0
- package/units/logger.js +59 -8
- package/units/noxt-dev.js +4 -2
- package/units/noxt-plugin.js +98 -98
- package/units/noxt-router.js +25 -8
- package/units/noxt.js +3 -1
- package/units/plugin.js +1 -1
- package/units/reload.js +101 -12
- package/units/services.js +16 -12
- package/units/static.js +43 -15
- package/units/utils.js +18 -1
- package/units.txt +86 -119
- package/units/noxt-router-dev.js +0 -33
package/units/fetch-cache-fs.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
'node-fetch-cache': '^1.0.0',
|
|
9
|
-
},
|
|
5
|
+
description: 'Cached fetch with stale-while-revalidate',
|
|
10
6
|
}
|
|
11
7
|
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
return
|
|
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
|
+
};
|
package/units/fetch-cache.js
CHANGED
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
|
|
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
|
-
'
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
'
|
|
14
|
-
|
|
13
|
+
'collect.hooks': ({
|
|
14
|
+
target: hooks,
|
|
15
|
+
is: 'function|boolean',
|
|
15
16
|
mode: 'object',
|
|
16
|
-
map: (fn, key) =>
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
'
|
|
34
|
+
'collect.on': {
|
|
35
|
+
target: hook_handlers,
|
|
23
36
|
is: 'function',
|
|
24
|
-
mode: '
|
|
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.
|
|
4
|
-
description: '
|
|
3
|
+
version: '1.0.1',
|
|
4
|
+
description: 'Noxt logger',
|
|
5
5
|
requires: ['express'],
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export default mlm => ({
|
|
9
|
-
'
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
});
|