webspresso 0.0.89 → 0.0.91
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 +450 -53
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/manager.js +36 -26
- package/core/auth/middleware.js +21 -2
- package/core/compression/compressible.js +51 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +301 -0
- package/core/compression/negotiate.js +153 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/orm/complexity.js +120 -0
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +45 -10
- package/core/orm/model.js +10 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/scopes.js +2 -1
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +284 -0
- package/core/queue/adapters/memory.js +155 -0
- package/core/queue/adapters/redis.js +336 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +387 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/index.d.ts +829 -52
- package/index.js +103 -0
- package/package.json +22 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +67 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
- package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +131 -18
- package/plugins/admin-panel/modules/admin-users.js +770 -0
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/content/client/inline-edit.js +41 -17
- package/plugins/cors.js +12 -8
- package/plugins/csrf/index.js +8 -8
- package/plugins/data-exchange/export-xlsx.js +5 -1
- package/plugins/data-exchange/import.js +16 -2
- package/plugins/data-exchange/index.js +14 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +29 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +220 -28
- package/src/helpers.js +24 -5
- package/src/plugin-manager.js +134 -7
- package/src/server.js +470 -44
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +140 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +288 -0
- package/src/services/validator.js +281 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso CLI: Service Command
|
|
3
|
+
* Run and test services interactively without starting the HTTP server
|
|
4
|
+
* @module bin/commands/service
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const inquirer = require('inquirer');
|
|
9
|
+
const { createServiceRegistry } = require('../../src/services');
|
|
10
|
+
const { resolveDbConfigIfExists, createDbInstance } = require('../utils/db');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse input string into an object
|
|
14
|
+
* @param {string} inputStr
|
|
15
|
+
* @returns {*}
|
|
16
|
+
*/
|
|
17
|
+
function parseInputPayload(inputStr) {
|
|
18
|
+
if (!inputStr || typeof inputStr !== 'string') {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const trimmed = inputStr.trim();
|
|
23
|
+
if (!trimmed) return {};
|
|
24
|
+
|
|
25
|
+
// Try JSON first
|
|
26
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(trimmed);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
throw new Error(`Invalid JSON input: ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Try key=value pairs (e.g. "id=42 name=Alice" or "id=42&name=Alice")
|
|
35
|
+
const result = {};
|
|
36
|
+
const pairs = trimmed.includes('&') ? trimmed.split('&') : trimmed.split(/\s+/);
|
|
37
|
+
|
|
38
|
+
for (const pair of pairs) {
|
|
39
|
+
if (!pair.includes('=')) continue;
|
|
40
|
+
const [rawKey, ...valParts] = pair.split('=');
|
|
41
|
+
const key = rawKey.trim();
|
|
42
|
+
const val = valParts.join('=').trim();
|
|
43
|
+
|
|
44
|
+
// Auto coerce simple types
|
|
45
|
+
if (val === 'true') result[key] = true;
|
|
46
|
+
else if (val === 'false') result[key] = false;
|
|
47
|
+
else if (val === 'null') result[key] = null;
|
|
48
|
+
else if (!Number.isNaN(Number(val)) && val !== '') result[key] = Number(val);
|
|
49
|
+
else result[key] = val;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return Object.keys(result).length > 0 ? result : trimmed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function registerCommand(program) {
|
|
56
|
+
program
|
|
57
|
+
.command('service [name]')
|
|
58
|
+
.alias('service:run')
|
|
59
|
+
.alias('service:call')
|
|
60
|
+
.description('Run and test services interactively without starting the HTTP server')
|
|
61
|
+
.option('-i, --input <payload>', 'Input payload as JSON string or key=val pairs')
|
|
62
|
+
.option('-d, --dir <path>', 'Path to services directory (default: ./services)')
|
|
63
|
+
.option('-c, --config <path>', 'Path to database config file (optional)')
|
|
64
|
+
.option('-E, --env <environment>', 'Environment (development, production)', 'development')
|
|
65
|
+
.action(async (nameArg, options) => {
|
|
66
|
+
let db = null;
|
|
67
|
+
try {
|
|
68
|
+
const servicesDir = options.dir
|
|
69
|
+
? path.resolve(process.cwd(), options.dir)
|
|
70
|
+
: path.resolve(process.cwd(), 'services');
|
|
71
|
+
|
|
72
|
+
const registry = createServiceRegistry({
|
|
73
|
+
servicesDir,
|
|
74
|
+
isDev: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const serviceNames = registry.list();
|
|
78
|
+
|
|
79
|
+
if (serviceNames.length === 0) {
|
|
80
|
+
console.log(`\n⚠️ No services found in "${path.relative(process.cwd(), servicesDir) || '.'}".`);
|
|
81
|
+
console.log(' Create your first service file in services/user/get.js\n');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let serviceName = nameArg;
|
|
86
|
+
|
|
87
|
+
// Interactive Service Selection if not provided
|
|
88
|
+
if (!serviceName) {
|
|
89
|
+
console.log(`\n⚡ Discovered ${serviceNames.length} service(s) in ${path.relative(process.cwd(), servicesDir) || 'services/'}:\n`);
|
|
90
|
+
|
|
91
|
+
const answer = await inquirer.prompt([
|
|
92
|
+
{
|
|
93
|
+
type: 'list',
|
|
94
|
+
name: 'selectedService',
|
|
95
|
+
message: 'Select a service to execute:',
|
|
96
|
+
choices: serviceNames.map((name) => {
|
|
97
|
+
const def = registry.get(name);
|
|
98
|
+
const hasSchema = !!def?.schema;
|
|
99
|
+
return {
|
|
100
|
+
name: `${name.padEnd(25)} ${hasSchema ? '📋 (has schema)' : ''}`,
|
|
101
|
+
value: name,
|
|
102
|
+
};
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
serviceName = answer.selectedService;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!registry.has(serviceName)) {
|
|
111
|
+
console.error(`\n❌ Error: Service "${serviceName}" not found.`);
|
|
112
|
+
console.log(` Available services: ${serviceNames.join(', ')}\n`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const serviceDef = registry.get(serviceName);
|
|
117
|
+
|
|
118
|
+
// Input gathering
|
|
119
|
+
let input = {};
|
|
120
|
+
if (options.input) {
|
|
121
|
+
input = parseInputPayload(options.input);
|
|
122
|
+
} else {
|
|
123
|
+
console.log(`\n⚙️ Target: \x1b[36m${serviceName}\x1b[0m (${serviceDef.filePath ? path.relative(process.cwd(), serviceDef.filePath) : 'in-memory'})`);
|
|
124
|
+
|
|
125
|
+
const inputAnswer = await inquirer.prompt([
|
|
126
|
+
{
|
|
127
|
+
type: 'input',
|
|
128
|
+
name: 'rawInput',
|
|
129
|
+
message: 'Enter input (JSON or key=val pairs, leave empty for {}):',
|
|
130
|
+
default: '{}',
|
|
131
|
+
},
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
input = parseInputPayload(inputAnswer.rawInput);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Initialize DB if config exists
|
|
138
|
+
const dbConfigResult = resolveDbConfigIfExists(options.config);
|
|
139
|
+
if (dbConfigResult) {
|
|
140
|
+
try {
|
|
141
|
+
db = await createDbInstance(dbConfigResult.config, options.env);
|
|
142
|
+
} catch (dbErr) {
|
|
143
|
+
console.warn(`\n⚠️ Could not connect to database: ${dbErr.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ctx = {
|
|
148
|
+
db,
|
|
149
|
+
user: { id: 1, role: 'admin', email: 'cli-admin@webspresso.local' },
|
|
150
|
+
logger: console,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
console.log(`\n🚀 Executing \x1b[36m${serviceName}\x1b[0m with input:`, JSON.stringify(input));
|
|
154
|
+
const startTime = Date.now();
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const result = await registry.call(serviceName, input, ctx);
|
|
158
|
+
const duration = Date.now() - startTime;
|
|
159
|
+
|
|
160
|
+
console.log(`\n✅ \x1b[32mExecution succeeded (${duration}ms)\x1b[0m\n`);
|
|
161
|
+
console.log(JSON.stringify(result, null, 2));
|
|
162
|
+
console.log('');
|
|
163
|
+
} catch (execErr) {
|
|
164
|
+
const duration = Date.now() - startTime;
|
|
165
|
+
console.error(`\n❌ \x1b[31mExecution failed (${duration}ms): ${execErr.message}\x1b[0m\n`);
|
|
166
|
+
|
|
167
|
+
if (execErr.fields) {
|
|
168
|
+
console.error('Validation Details:');
|
|
169
|
+
for (const [f, msg] of Object.entries(execErr.fields)) {
|
|
170
|
+
console.error(` - ${f}: ${msg}`);
|
|
171
|
+
}
|
|
172
|
+
console.error('');
|
|
173
|
+
} else if (execErr.stack) {
|
|
174
|
+
console.error(execErr.stack);
|
|
175
|
+
console.error('');
|
|
176
|
+
}
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error(`\n❌ CLI Error: ${err.message}\n`);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
} finally {
|
|
183
|
+
if (db && typeof db.destroy === 'function') {
|
|
184
|
+
await db.destroy();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
registerCommand,
|
|
192
|
+
parseInputPayload,
|
|
193
|
+
};
|
package/bin/webspresso.js
CHANGED
|
@@ -33,6 +33,7 @@ const { registerCommand: registerDoctor } = require('./commands/doctor');
|
|
|
33
33
|
const { registerCommand: registerSkill } = require('./commands/skill');
|
|
34
34
|
const { registerCommand: registerUpgrade } = require('./commands/upgrade');
|
|
35
35
|
const { registerCommand: registerOrmMap } = require('./commands/orm-map');
|
|
36
|
+
const { registerCommand: registerService } = require('./commands/service');
|
|
36
37
|
|
|
37
38
|
registerNew(program);
|
|
38
39
|
registerPage(program);
|
|
@@ -54,6 +55,7 @@ registerDoctor(program);
|
|
|
54
55
|
registerSkill(program);
|
|
55
56
|
registerUpgrade(program);
|
|
56
57
|
registerOrmMap(program);
|
|
58
|
+
registerService(program);
|
|
57
59
|
|
|
58
60
|
// Parse arguments
|
|
59
61
|
program.parse();
|
package/core/auth/manager.js
CHANGED
|
@@ -38,7 +38,7 @@ const DEFAULT_CONFIG = {
|
|
|
38
38
|
saveUninitialized: false,
|
|
39
39
|
cookie: {
|
|
40
40
|
httpOnly: true,
|
|
41
|
-
secure: process.env.NODE_ENV === 'production',
|
|
41
|
+
secure: process.env.NODE_ENV === 'production' ? true : 'auto',
|
|
42
42
|
sameSite: 'lax',
|
|
43
43
|
maxAge: 24 * 60 * 60 * 1000, // 1 day
|
|
44
44
|
},
|
|
@@ -272,9 +272,11 @@ class AuthManager {
|
|
|
272
272
|
throw new AuthenticationError('Invalid user object', 'INVALID_USER');
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
// Set session
|
|
276
|
-
req.session
|
|
277
|
-
|
|
275
|
+
// Set session if active
|
|
276
|
+
if (req.session) {
|
|
277
|
+
req.session.userId = user.id;
|
|
278
|
+
req.session.loggedInAt = Date.now();
|
|
279
|
+
}
|
|
278
280
|
|
|
279
281
|
// Set req.user
|
|
280
282
|
req.user = user;
|
|
@@ -284,19 +286,21 @@ class AuthManager {
|
|
|
284
286
|
await self.createRememberToken(user.id, res);
|
|
285
287
|
}
|
|
286
288
|
|
|
287
|
-
// Regenerate session ID for security
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
289
|
+
// Regenerate session ID for security if supported
|
|
290
|
+
if (req.session && typeof req.session.regenerate === 'function') {
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
req.session.regenerate((err) => {
|
|
293
|
+
if (err) {
|
|
294
|
+
reject(new AuthenticationError('Session regeneration failed', 'SESSION_ERROR'));
|
|
295
|
+
} else {
|
|
296
|
+
// Restore userId after regenerate
|
|
297
|
+
req.session.userId = user.id;
|
|
298
|
+
req.session.loggedInAt = Date.now();
|
|
299
|
+
resolve();
|
|
300
|
+
}
|
|
301
|
+
});
|
|
298
302
|
});
|
|
299
|
-
}
|
|
303
|
+
}
|
|
300
304
|
},
|
|
301
305
|
|
|
302
306
|
/**
|
|
@@ -320,21 +324,25 @@ class AuthManager {
|
|
|
320
324
|
}
|
|
321
325
|
|
|
322
326
|
// Clear cookie
|
|
323
|
-
res
|
|
327
|
+
if (typeof res?.clearCookie === 'function') {
|
|
328
|
+
res.clearCookie(self.config.rememberMe.cookieName);
|
|
329
|
+
}
|
|
324
330
|
}
|
|
325
331
|
|
|
326
332
|
// Clear user
|
|
327
333
|
req.user = null;
|
|
328
334
|
|
|
329
|
-
// Destroy session
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
// Destroy session if supported
|
|
336
|
+
if (req.session && typeof req.session.destroy === 'function') {
|
|
337
|
+
return new Promise((resolve) => {
|
|
338
|
+
req.session.destroy((err) => {
|
|
339
|
+
if (err) {
|
|
340
|
+
console.error('Session destroy error:', err);
|
|
341
|
+
}
|
|
342
|
+
resolve();
|
|
343
|
+
});
|
|
336
344
|
});
|
|
337
|
-
}
|
|
345
|
+
}
|
|
338
346
|
},
|
|
339
347
|
|
|
340
348
|
/**
|
|
@@ -715,7 +723,9 @@ class AuthManager {
|
|
|
715
723
|
const payload = {
|
|
716
724
|
id: user.id,
|
|
717
725
|
type: 'access',
|
|
718
|
-
[this.identifierField]: user[this.identifierField],
|
|
726
|
+
...(this.identifierField && user[this.identifierField] ? { [this.identifierField]: user[this.identifierField] } : {}),
|
|
727
|
+
...(user.email && { email: user.email }),
|
|
728
|
+
...(user.role && { role: user.role }),
|
|
719
729
|
...options.payload,
|
|
720
730
|
};
|
|
721
731
|
return this.createJwt(payload, options);
|
package/core/auth/middleware.js
CHANGED
|
@@ -15,11 +15,30 @@ const cookieParser = require('cookie-parser');
|
|
|
15
15
|
function createAuthMiddleware(authManager) {
|
|
16
16
|
const config = authManager.config;
|
|
17
17
|
|
|
18
|
+
const sessionConfig = authManager.getSessionConfig();
|
|
19
|
+
|
|
20
|
+
if (!sessionConfig.store && process.env.NODE_ENV === 'production') {
|
|
21
|
+
const warnMsg = '[webspresso:auth] Warning: Default MemoryStore is being used in production. It is not designed for production environments and will leak memory under high load. Please configure a persistent session store (e.g. Knex or Redis).';
|
|
22
|
+
if (typeof process !== 'undefined' && typeof process.emitWarning === 'function') {
|
|
23
|
+
process.emitWarning(warnMsg, 'SecurityWarning');
|
|
24
|
+
} else {
|
|
25
|
+
console.warn(warnMsg);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* Session middleware
|
|
20
|
-
* Sets up express-session
|
|
31
|
+
* Sets up express-session with secure cookie enforcement
|
|
21
32
|
*/
|
|
22
|
-
const sessionMiddleware = session(
|
|
33
|
+
const sessionMiddleware = session({
|
|
34
|
+
...sessionConfig,
|
|
35
|
+
cookie: {
|
|
36
|
+
httpOnly: true,
|
|
37
|
+
sameSite: 'lax',
|
|
38
|
+
secure: 'auto',
|
|
39
|
+
...(sessionConfig.cookie || {}),
|
|
40
|
+
},
|
|
41
|
+
});
|
|
23
42
|
|
|
24
43
|
/**
|
|
25
44
|
* Cookie parser middleware (needed for signed cookies / remember me)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Type Compressibility Filter
|
|
3
|
+
* Determines if a given MIME type benefits from HTTP compression.
|
|
4
|
+
* @module core/compression/compressible
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Regex patterns for MIME types that benefit from compression
|
|
8
|
+
const COMPRESSIBLE_TYPE_REGEX = /^(?:text\/[a-z0-9.+_-]+|application\/(?:javascript|x-javascript|json|ld\+json|manifest\+json|graphql\+json|geo\+json|xml|atom\+xml|rss\+xml|xhtml\+xml|x-yaml|yaml|vnd\.api\+json|schema\+json|hal\+json)|image\/svg\+xml)$/i;
|
|
9
|
+
|
|
10
|
+
// Regex patterns for already-compressed or non-compressible types
|
|
11
|
+
const INCOMPRESSIBLE_TYPE_REGEX = /^(?:image\/(?:jpeg|png|gif|webp|avif|heic|heif)|video\/[a-z0-9.+_-]+|audio\/[a-z0-9.+_-]+|application\/(?:zip|gzip|x-gzip|x-bzip2|x-compress|x-7z-compressed|x-rar-compressed|pdf|wasm|octet-stream))$/i;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Determine if a Content-Type is compressible
|
|
15
|
+
* @param {string|undefined|null} contentType - Raw Content-Type header (e.g. 'text/html; charset=utf-8')
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
function isCompressible(contentType) {
|
|
19
|
+
if (!contentType || typeof contentType !== 'string') {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Extract base MIME type (strip parameters like ; charset=utf-8)
|
|
24
|
+
const semiIndex = contentType.indexOf(';');
|
|
25
|
+
const mime = (semiIndex !== -1 ? contentType.slice(0, semiIndex) : contentType).trim().toLowerCase();
|
|
26
|
+
|
|
27
|
+
if (!mime) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Fast negative check
|
|
32
|
+
if (INCOMPRESSIBLE_TYPE_REGEX.test(mime)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check compressible match
|
|
37
|
+
if (COMPRESSIBLE_TYPE_REGEX.test(mime)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// General text or suffix match (+json, +xml, +yaml)
|
|
42
|
+
if (mime.startsWith('text/') || mime.endsWith('+json') || mime.endsWith('+xml') || mime.endsWith('+yaml')) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
isCompressible,
|
|
51
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compression Stream Factory
|
|
3
|
+
* Instantiates native node:zlib transform streams (brotli, gzip, deflate).
|
|
4
|
+
* @module core/compression/compression-stream
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const zlib = require('zlib');
|
|
8
|
+
const { supportsBrotli } = require('./negotiate');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a zlib compression transform stream based on encoding
|
|
12
|
+
* @param {'br'|'gzip'|'deflate'} encoding
|
|
13
|
+
* @param {Object} [options]
|
|
14
|
+
* @param {number} [options.level=6] - Default compression level (0-9 for gzip/deflate, 0-11 for brotli)
|
|
15
|
+
* @param {Object} [options.brotli] - Custom options for zlib.createBrotliCompress
|
|
16
|
+
* @param {Object} [options.gzip] - Custom options for zlib.createGzip
|
|
17
|
+
* @param {Object} [options.deflate] - Custom options for zlib.createDeflate
|
|
18
|
+
* @returns {import('stream').Transform|null}
|
|
19
|
+
*/
|
|
20
|
+
function createCompressionStream(encoding, options = {}) {
|
|
21
|
+
const enc = (encoding || '').toLowerCase();
|
|
22
|
+
const level = typeof options.level === 'number' ? options.level : 6;
|
|
23
|
+
|
|
24
|
+
switch (enc) {
|
|
25
|
+
case 'br': {
|
|
26
|
+
if (!supportsBrotli()) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const brotliOpts = { ...(options.brotli || {}) };
|
|
30
|
+
if (!brotliOpts.params) {
|
|
31
|
+
brotliOpts.params = {};
|
|
32
|
+
}
|
|
33
|
+
if (brotliOpts.params[zlib.constants.BROTLI_PARAM_QUALITY] === undefined) {
|
|
34
|
+
// Brotli quality 4 is standard for dynamic HTTP compression (fast & high ratio)
|
|
35
|
+
brotliOpts.params[zlib.constants.BROTLI_PARAM_QUALITY] = typeof options.brotli?.quality === 'number'
|
|
36
|
+
? options.brotli.quality
|
|
37
|
+
: (options.level !== undefined ? options.level : 4);
|
|
38
|
+
}
|
|
39
|
+
return zlib.createBrotliCompress(brotliOpts);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
case 'gzip': {
|
|
43
|
+
const gzipOpts = { level, ...(options.gzip || {}) };
|
|
44
|
+
return zlib.createGzip(gzipOpts);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case 'deflate': {
|
|
48
|
+
const deflateOpts = { level, ...(options.deflate || {}) };
|
|
49
|
+
return zlib.createDeflate(deflateOpts);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
default:
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
createCompressionStream,
|
|
59
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso HTTP Response Compression Module
|
|
3
|
+
* @module core/compression
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
supportsBrotli,
|
|
8
|
+
getDefaultSupportedEncodings,
|
|
9
|
+
parseAcceptEncoding,
|
|
10
|
+
selectEncoding,
|
|
11
|
+
} = require('./negotiate');
|
|
12
|
+
const { isCompressible } = require('./compressible');
|
|
13
|
+
const { createCompressionStream } = require('./compression-stream');
|
|
14
|
+
const { createCompressionMiddleware, appendVary } = require('./middleware');
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
supportsBrotli,
|
|
18
|
+
getDefaultSupportedEncodings,
|
|
19
|
+
parseAcceptEncoding,
|
|
20
|
+
selectEncoding,
|
|
21
|
+
isCompressible,
|
|
22
|
+
createCompressionStream,
|
|
23
|
+
createCompressionMiddleware,
|
|
24
|
+
appendVary,
|
|
25
|
+
};
|