voltjs-framework 1.0.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/LICENSE +21 -0
- package/README.md +1265 -0
- package/bin/volt.js +139 -0
- package/package.json +56 -0
- package/src/api/graphql.js +399 -0
- package/src/api/rest.js +204 -0
- package/src/api/websocket.js +285 -0
- package/src/cli/build.js +111 -0
- package/src/cli/create.js +371 -0
- package/src/cli/db.js +106 -0
- package/src/cli/dev.js +114 -0
- package/src/cli/generate.js +278 -0
- package/src/cli/lint.js +172 -0
- package/src/cli/routes.js +118 -0
- package/src/cli/start.js +42 -0
- package/src/cli/test.js +138 -0
- package/src/core/app.js +701 -0
- package/src/core/config.js +232 -0
- package/src/core/middleware.js +133 -0
- package/src/core/plugins.js +88 -0
- package/src/core/react-renderer.js +244 -0
- package/src/core/renderer.js +337 -0
- package/src/core/router.js +183 -0
- package/src/database/index.js +461 -0
- package/src/database/migration.js +192 -0
- package/src/database/model.js +285 -0
- package/src/database/query.js +394 -0
- package/src/database/seeder.js +89 -0
- package/src/index.js +156 -0
- package/src/security/auth.js +425 -0
- package/src/security/cors.js +80 -0
- package/src/security/csrf.js +125 -0
- package/src/security/encryption.js +110 -0
- package/src/security/helmet.js +103 -0
- package/src/security/index.js +75 -0
- package/src/security/rateLimit.js +119 -0
- package/src/security/sanitizer.js +113 -0
- package/src/security/xss.js +110 -0
- package/src/ui/component.js +224 -0
- package/src/ui/reactive.js +503 -0
- package/src/ui/template.js +448 -0
- package/src/utils/cache.js +216 -0
- package/src/utils/collection.js +772 -0
- package/src/utils/cron.js +213 -0
- package/src/utils/date.js +223 -0
- package/src/utils/events.js +181 -0
- package/src/utils/excel.js +482 -0
- package/src/utils/form.js +547 -0
- package/src/utils/hash.js +121 -0
- package/src/utils/http.js +461 -0
- package/src/utils/logger.js +186 -0
- package/src/utils/mail.js +347 -0
- package/src/utils/paginator.js +179 -0
- package/src/utils/pdf.js +417 -0
- package/src/utils/queue.js +199 -0
- package/src/utils/schema.js +985 -0
- package/src/utils/sms.js +243 -0
- package/src/utils/storage.js +348 -0
- package/src/utils/string.js +236 -0
- package/src/utils/validation.js +318 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Configuration Manager
|
|
3
|
+
*
|
|
4
|
+
* Handles configuration with:
|
|
5
|
+
* - Default values
|
|
6
|
+
* - Environment variable overrides
|
|
7
|
+
* - Config file loading (volt.config.js)
|
|
8
|
+
* - Nested key access with dot notation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
|
|
16
|
+
const DEFAULTS = {
|
|
17
|
+
env: process.env.NODE_ENV || 'development',
|
|
18
|
+
port: parseInt(process.env.PORT, 10) || 3000,
|
|
19
|
+
host: process.env.HOST || '0.0.0.0',
|
|
20
|
+
|
|
21
|
+
// Security defaults - ON by default
|
|
22
|
+
security: {
|
|
23
|
+
csrf: true,
|
|
24
|
+
xss: true,
|
|
25
|
+
cors: {
|
|
26
|
+
origin: '*',
|
|
27
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
28
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
|
29
|
+
credentials: true,
|
|
30
|
+
maxAge: 86400,
|
|
31
|
+
},
|
|
32
|
+
rateLimit: {
|
|
33
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
34
|
+
max: 100, // 100 requests per window
|
|
35
|
+
},
|
|
36
|
+
headers: true,
|
|
37
|
+
hsts: true,
|
|
38
|
+
sanitize: true,
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
// Server defaults
|
|
42
|
+
server: {
|
|
43
|
+
maxBodySize: 10 * 1024 * 1024, // 10MB
|
|
44
|
+
timeout: 30000, // 30s
|
|
45
|
+
keepAlive: true,
|
|
46
|
+
compression: true,
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// Logging
|
|
50
|
+
logging: {
|
|
51
|
+
requests: true,
|
|
52
|
+
level: 'info',
|
|
53
|
+
format: 'pretty',
|
|
54
|
+
file: null,
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// Views
|
|
58
|
+
views: {
|
|
59
|
+
engine: 'volt',
|
|
60
|
+
dir: 'views',
|
|
61
|
+
cache: process.env.NODE_ENV === 'production',
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// Database
|
|
65
|
+
database: {
|
|
66
|
+
driver: null,
|
|
67
|
+
host: 'localhost',
|
|
68
|
+
port: null,
|
|
69
|
+
name: null,
|
|
70
|
+
user: null,
|
|
71
|
+
password: null,
|
|
72
|
+
filename: null,
|
|
73
|
+
pool: { min: 2, max: 10 },
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// Cache
|
|
77
|
+
cache: {
|
|
78
|
+
driver: 'memory',
|
|
79
|
+
ttl: 3600,
|
|
80
|
+
prefix: 'volt:',
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// Mail
|
|
84
|
+
mail: {
|
|
85
|
+
driver: 'smtp',
|
|
86
|
+
host: null,
|
|
87
|
+
port: 587,
|
|
88
|
+
secure: false,
|
|
89
|
+
user: null,
|
|
90
|
+
password: null,
|
|
91
|
+
from: null,
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// Session
|
|
95
|
+
session: {
|
|
96
|
+
secret: null,
|
|
97
|
+
name: 'volt.sid',
|
|
98
|
+
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
99
|
+
secure: false,
|
|
100
|
+
httpOnly: true,
|
|
101
|
+
sameSite: 'Lax',
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
class Config {
|
|
106
|
+
constructor(userConfig = {}) {
|
|
107
|
+
// Deep merge: defaults → config file → user config → env vars
|
|
108
|
+
this._config = this._deepMerge(DEFAULTS, this._loadConfigFile(), userConfig);
|
|
109
|
+
this._applyEnvOverrides();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Get config value using dot notation */
|
|
113
|
+
get(key, defaultValue = undefined) {
|
|
114
|
+
const keys = key.split('.');
|
|
115
|
+
let value = this._config;
|
|
116
|
+
|
|
117
|
+
for (const k of keys) {
|
|
118
|
+
if (value === undefined || value === null) return defaultValue;
|
|
119
|
+
value = value[k];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return value !== undefined ? value : defaultValue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Set config value using dot notation */
|
|
126
|
+
set(key, value) {
|
|
127
|
+
const keys = key.split('.');
|
|
128
|
+
let target = this._config;
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
131
|
+
if (!(keys[i] in target)) target[keys[i]] = {};
|
|
132
|
+
target = target[keys[i]];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
target[keys[keys.length - 1]] = value;
|
|
136
|
+
return this;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Check if key exists */
|
|
140
|
+
has(key) {
|
|
141
|
+
return this.get(key) !== undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Get all config */
|
|
145
|
+
all() {
|
|
146
|
+
return { ...this._config };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ===== INTERNAL =====
|
|
150
|
+
|
|
151
|
+
_loadConfigFile() {
|
|
152
|
+
const configPaths = [
|
|
153
|
+
path.join(process.cwd(), 'volt.config.js'),
|
|
154
|
+
path.join(process.cwd(), 'volt.config.json'),
|
|
155
|
+
path.join(process.cwd(), '.voltrc'),
|
|
156
|
+
path.join(process.cwd(), '.voltrc.js'),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
for (const configPath of configPaths) {
|
|
160
|
+
try {
|
|
161
|
+
if (fs.existsSync(configPath)) {
|
|
162
|
+
return require(configPath);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Skip invalid config files
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_applyEnvOverrides() {
|
|
173
|
+
// Map common ENV vars to config keys
|
|
174
|
+
const envMap = {
|
|
175
|
+
VOLT_PORT: 'port',
|
|
176
|
+
VOLT_HOST: 'host',
|
|
177
|
+
VOLT_ENV: 'env',
|
|
178
|
+
NODE_ENV: 'env',
|
|
179
|
+
DATABASE_URL: 'database.url',
|
|
180
|
+
DB_HOST: 'database.host',
|
|
181
|
+
DB_PORT: 'database.port',
|
|
182
|
+
DB_NAME: 'database.name',
|
|
183
|
+
DB_USER: 'database.user',
|
|
184
|
+
DB_PASSWORD: 'database.password',
|
|
185
|
+
MAIL_HOST: 'mail.host',
|
|
186
|
+
MAIL_PORT: 'mail.port',
|
|
187
|
+
MAIL_USER: 'mail.user',
|
|
188
|
+
MAIL_PASSWORD: 'mail.password',
|
|
189
|
+
MAIL_FROM: 'mail.from',
|
|
190
|
+
SESSION_SECRET: 'session.secret',
|
|
191
|
+
CACHE_DRIVER: 'cache.driver',
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
for (const [envKey, configKey] of Object.entries(envMap)) {
|
|
195
|
+
if (process.env[envKey] !== undefined) {
|
|
196
|
+
let value = process.env[envKey];
|
|
197
|
+
// Auto-convert numbers and booleans
|
|
198
|
+
if (value === 'true') value = true;
|
|
199
|
+
else if (value === 'false') value = false;
|
|
200
|
+
else if (/^\d+$/.test(value)) value = parseInt(value, 10);
|
|
201
|
+
this.set(configKey, value);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_deepMerge(target, ...sources) {
|
|
207
|
+
const result = { ...target };
|
|
208
|
+
|
|
209
|
+
for (const source of sources) {
|
|
210
|
+
if (!source || typeof source !== 'object') continue;
|
|
211
|
+
|
|
212
|
+
for (const key of Object.keys(source)) {
|
|
213
|
+
if (
|
|
214
|
+
source[key] &&
|
|
215
|
+
typeof source[key] === 'object' &&
|
|
216
|
+
!Array.isArray(source[key]) &&
|
|
217
|
+
result[key] &&
|
|
218
|
+
typeof result[key] === 'object' &&
|
|
219
|
+
!Array.isArray(result[key])
|
|
220
|
+
) {
|
|
221
|
+
result[key] = this._deepMerge(result[key], source[key]);
|
|
222
|
+
} else {
|
|
223
|
+
result[key] = source[key];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { Config };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Middleware Engine
|
|
3
|
+
*
|
|
4
|
+
* Supports global middleware, path-specific middleware, and named middleware.
|
|
5
|
+
* All middleware follows (req, res, next?) pattern.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
class Middleware {
|
|
11
|
+
constructor() {
|
|
12
|
+
this._global = [];
|
|
13
|
+
this._paths = new Map();
|
|
14
|
+
this._named = new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Add global middleware */
|
|
18
|
+
addGlobal(fn) {
|
|
19
|
+
if (typeof fn !== 'function') {
|
|
20
|
+
throw new Error('Middleware must be a function');
|
|
21
|
+
}
|
|
22
|
+
this._global.push(fn);
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Add path-specific middleware */
|
|
27
|
+
addPath(pathPattern, fns) {
|
|
28
|
+
const handlers = Array.isArray(fns) ? fns : [fns];
|
|
29
|
+
if (!this._paths.has(pathPattern)) {
|
|
30
|
+
this._paths.set(pathPattern, []);
|
|
31
|
+
}
|
|
32
|
+
this._paths.get(pathPattern).push(...handlers);
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Register named middleware for reuse */
|
|
37
|
+
register(name, fn) {
|
|
38
|
+
this._named.set(name, fn);
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Get named middleware */
|
|
43
|
+
get(name) {
|
|
44
|
+
return this._named.get(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Run global middleware chain */
|
|
48
|
+
async runGlobal(req, res) {
|
|
49
|
+
return this._runChain(this._global, req, res);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Run path-specific middleware */
|
|
53
|
+
async runPath(requestPath, req, res) {
|
|
54
|
+
const matchingMiddleware = [];
|
|
55
|
+
|
|
56
|
+
for (const [pathPattern, handlers] of this._paths) {
|
|
57
|
+
if (this._pathMatches(requestPath, pathPattern)) {
|
|
58
|
+
matchingMiddleware.push(...handlers);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (matchingMiddleware.length === 0) return true;
|
|
63
|
+
return this._runChain(matchingMiddleware, req, res);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Execute middleware chain */
|
|
67
|
+
async _runChain(middlewares, req, res) {
|
|
68
|
+
for (const mw of middlewares) {
|
|
69
|
+
if (res.writableEnded) return false;
|
|
70
|
+
|
|
71
|
+
const result = await new Promise((resolve, reject) => {
|
|
72
|
+
try {
|
|
73
|
+
// Support both (req, res, next) and (req, res) patterns
|
|
74
|
+
if (mw.length >= 3) {
|
|
75
|
+
const ret = mw(req, res, (err) => {
|
|
76
|
+
if (err) reject(err);
|
|
77
|
+
else resolve(true);
|
|
78
|
+
});
|
|
79
|
+
// Handle async middleware
|
|
80
|
+
if (ret && typeof ret.then === 'function') {
|
|
81
|
+
ret.catch(reject);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
const ret = mw(req, res);
|
|
85
|
+
if (ret && typeof ret.then === 'function') {
|
|
86
|
+
ret.then(r => resolve(r !== false)).catch(reject);
|
|
87
|
+
} else {
|
|
88
|
+
resolve(ret !== false);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
reject(err);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (result === false) return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Check if a request path matches a middleware path pattern */
|
|
102
|
+
_pathMatches(requestPath, pattern) {
|
|
103
|
+
if (pattern === '*' || pattern === '/') return true;
|
|
104
|
+
|
|
105
|
+
// Exact match
|
|
106
|
+
if (requestPath === pattern) return true;
|
|
107
|
+
|
|
108
|
+
// Prefix match (e.g., '/admin' matches '/admin/users')
|
|
109
|
+
if (requestPath.startsWith(pattern + '/')) return true;
|
|
110
|
+
|
|
111
|
+
// Wildcard pattern
|
|
112
|
+
if (pattern.includes('*')) {
|
|
113
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
114
|
+
return regex.test(requestPath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Create composed middleware from multiple middleware functions */
|
|
121
|
+
static compose(...middlewares) {
|
|
122
|
+
return async (req, res) => {
|
|
123
|
+
for (const mw of middlewares.flat()) {
|
|
124
|
+
if (res.writableEnded) return false;
|
|
125
|
+
const result = typeof mw === 'function' ? await mw(req, res) : undefined;
|
|
126
|
+
if (result === false) return false;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { Middleware };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Plugin Manager
|
|
3
|
+
*
|
|
4
|
+
* Extensible plugin system for adding functionality.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* // Creating a plugin
|
|
8
|
+
* const myPlugin = {
|
|
9
|
+
* name: 'my-plugin',
|
|
10
|
+
* version: '1.0.0',
|
|
11
|
+
* install(app, options) {
|
|
12
|
+
* app.get('/my-route', (req, res) => res.json({ plugin: true }));
|
|
13
|
+
* app.use((req, res) => { req.myPlugin = true; });
|
|
14
|
+
* }
|
|
15
|
+
* };
|
|
16
|
+
*
|
|
17
|
+
* app.use(myPlugin);
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
class PluginManager {
|
|
23
|
+
constructor(app) {
|
|
24
|
+
this._app = app;
|
|
25
|
+
this._plugins = new Map();
|
|
26
|
+
this._hooks = new Map();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Register a plugin */
|
|
30
|
+
register(plugin, options = {}) {
|
|
31
|
+
if (!plugin || !plugin.name) {
|
|
32
|
+
throw new Error('Plugin must have a name property');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (this._plugins.has(plugin.name)) {
|
|
36
|
+
console.warn(`\x1b[33mWarning: Plugin "${plugin.name}" is already registered. Skipping.\x1b[0m`);
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Validate plugin
|
|
41
|
+
if (typeof plugin.install !== 'function') {
|
|
42
|
+
throw new Error(`Plugin "${plugin.name}" must have an install() method`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Install plugin
|
|
46
|
+
try {
|
|
47
|
+
plugin.install(this._app, options);
|
|
48
|
+
this._plugins.set(plugin.name, { plugin, options });
|
|
49
|
+
console.log(`\x1b[32m ✓ Plugin loaded: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''}\x1b[0m`);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`\x1b[31m ✗ Plugin "${plugin.name}" failed to install: ${err.message}\x1b[0m`);
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Check if a plugin is registered */
|
|
59
|
+
has(name) {
|
|
60
|
+
return this._plugins.has(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Get a registered plugin */
|
|
64
|
+
get(name) {
|
|
65
|
+
const entry = this._plugins.get(name);
|
|
66
|
+
return entry ? entry.plugin : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** List all registered plugins */
|
|
70
|
+
list() {
|
|
71
|
+
return Array.from(this._plugins.entries()).map(([name, { plugin }]) => ({
|
|
72
|
+
name,
|
|
73
|
+
version: plugin.version || '0.0.0',
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Unregister a plugin */
|
|
78
|
+
unregister(name) {
|
|
79
|
+
const entry = this._plugins.get(name);
|
|
80
|
+
if (entry && typeof entry.plugin.uninstall === 'function') {
|
|
81
|
+
entry.plugin.uninstall(this._app);
|
|
82
|
+
}
|
|
83
|
+
this._plugins.delete(name);
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { PluginManager };
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS React Server-Side Renderer
|
|
3
|
+
*
|
|
4
|
+
* Renders React components to HTML on the server using ReactDOMServer.
|
|
5
|
+
* Supports:
|
|
6
|
+
* - renderToString (full SSR with hydration support)
|
|
7
|
+
* - renderToStaticMarkup (static HTML, no React client overhead)
|
|
8
|
+
* - Layout wrapping with <head>, styles, scripts
|
|
9
|
+
* - Passing initial props/state to client for hydration
|
|
10
|
+
* - Component registry for file-based component discovery
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const { ReactRenderer } = require('./react-renderer');
|
|
14
|
+
* const renderer = new ReactRenderer();
|
|
15
|
+
*
|
|
16
|
+
* // Register a component
|
|
17
|
+
* renderer.register('App', AppComponent);
|
|
18
|
+
*
|
|
19
|
+
* // Render to full HTML page
|
|
20
|
+
* const html = renderer.renderPage('App', { tasks: [...] }, {
|
|
21
|
+
* title: 'My App',
|
|
22
|
+
* styles: ['/css/app.css'],
|
|
23
|
+
* });
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const React = require('react');
|
|
29
|
+
const ReactDOMServer = require('react-dom/server');
|
|
30
|
+
|
|
31
|
+
class ReactRenderer {
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this._components = new Map();
|
|
34
|
+
this._layouts = new Map();
|
|
35
|
+
this._defaultLayout = options.layout || null;
|
|
36
|
+
this._staticMode = options.static || false; // true = renderToStaticMarkup
|
|
37
|
+
this._doctype = options.doctype !== false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register a React component by name
|
|
42
|
+
* @param {string} name - Component name
|
|
43
|
+
* @param {React.Component|Function} component - React component
|
|
44
|
+
*/
|
|
45
|
+
register(name, component) {
|
|
46
|
+
this._components.set(name, component);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register a layout component
|
|
52
|
+
* @param {string} name - Layout name
|
|
53
|
+
* @param {React.Component|Function} layout - Layout component that accepts { children, ...props }
|
|
54
|
+
*/
|
|
55
|
+
layout(name, layout) {
|
|
56
|
+
this._layouts.set(name, layout);
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set the default layout
|
|
62
|
+
* @param {string} name - Layout name
|
|
63
|
+
*/
|
|
64
|
+
setDefaultLayout(name) {
|
|
65
|
+
this._defaultLayout = name;
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Render a React element to HTML string
|
|
71
|
+
* @param {React.Element} element - React element to render
|
|
72
|
+
* @returns {string} HTML string
|
|
73
|
+
*/
|
|
74
|
+
renderToString(element) {
|
|
75
|
+
if (this._staticMode) {
|
|
76
|
+
return ReactDOMServer.renderToStaticMarkup(element);
|
|
77
|
+
}
|
|
78
|
+
return ReactDOMServer.renderToString(element);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Render a registered component by name
|
|
83
|
+
* @param {string} name - Component name
|
|
84
|
+
* @param {object} props - Props to pass to the component
|
|
85
|
+
* @returns {string} HTML string
|
|
86
|
+
*/
|
|
87
|
+
render(name, props = {}) {
|
|
88
|
+
const Component = this._components.get(name);
|
|
89
|
+
if (!Component) {
|
|
90
|
+
throw new Error(`React component "${name}" not registered. Use renderer.register('${name}', Component) first.`);
|
|
91
|
+
}
|
|
92
|
+
const element = React.createElement(Component, props);
|
|
93
|
+
return this.renderToString(element);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Render a component directly (not registered)
|
|
98
|
+
* @param {React.Component|Function} Component - React component
|
|
99
|
+
* @param {object} props - Props
|
|
100
|
+
* @returns {string} HTML string
|
|
101
|
+
*/
|
|
102
|
+
renderComponent(Component, props = {}) {
|
|
103
|
+
const element = React.createElement(Component, props);
|
|
104
|
+
return this.renderToString(element);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render a full HTML page with a React component as body
|
|
109
|
+
* @param {string|React.Component|Function} componentOrName - Component name or component itself
|
|
110
|
+
* @param {object} props - Props for the component
|
|
111
|
+
* @param {object} pageOptions - Page-level options
|
|
112
|
+
* @param {string} pageOptions.title - Page title
|
|
113
|
+
* @param {string[]} pageOptions.styles - CSS file paths
|
|
114
|
+
* @param {string[]} pageOptions.scripts - JS file paths
|
|
115
|
+
* @param {string} pageOptions.layout - Layout name to use
|
|
116
|
+
* @param {object} pageOptions.meta - Meta tags { name: content }
|
|
117
|
+
* @param {string} pageOptions.lang - HTML lang attribute
|
|
118
|
+
* @param {boolean} pageOptions.hydrate - Include hydration script + initial state
|
|
119
|
+
* @param {string} pageOptions.containerId - Root container ID (default: 'root')
|
|
120
|
+
* @param {string} pageOptions.inlineStyles - Inline CSS string
|
|
121
|
+
* @param {string} pageOptions.headHtml - Extra HTML to inject in <head>
|
|
122
|
+
* @returns {string} Full HTML document
|
|
123
|
+
*/
|
|
124
|
+
renderPage(componentOrName, props = {}, pageOptions = {}) {
|
|
125
|
+
const {
|
|
126
|
+
title = 'VoltJS App',
|
|
127
|
+
styles = [],
|
|
128
|
+
scripts = [],
|
|
129
|
+
layout = this._defaultLayout,
|
|
130
|
+
meta = {},
|
|
131
|
+
lang = 'en',
|
|
132
|
+
hydrate = false,
|
|
133
|
+
containerId = 'root',
|
|
134
|
+
inlineStyles = '',
|
|
135
|
+
headHtml = '',
|
|
136
|
+
} = pageOptions;
|
|
137
|
+
|
|
138
|
+
// Resolve the component
|
|
139
|
+
let Component;
|
|
140
|
+
let componentName;
|
|
141
|
+
if (typeof componentOrName === 'string') {
|
|
142
|
+
Component = this._components.get(componentOrName);
|
|
143
|
+
componentName = componentOrName;
|
|
144
|
+
if (!Component) {
|
|
145
|
+
throw new Error(`React component "${componentOrName}" not registered.`);
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
Component = componentOrName;
|
|
149
|
+
componentName = Component.displayName || Component.name || 'Component';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Create the React element
|
|
153
|
+
let element = React.createElement(Component, props);
|
|
154
|
+
|
|
155
|
+
// Wrap in layout if specified
|
|
156
|
+
if (layout) {
|
|
157
|
+
const Layout = this._layouts.get(layout);
|
|
158
|
+
if (Layout) {
|
|
159
|
+
element = React.createElement(Layout, { ...props, title }, element);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Render to HTML
|
|
164
|
+
const bodyHtml = this.renderToString(element);
|
|
165
|
+
|
|
166
|
+
// Build meta tags
|
|
167
|
+
const metaTags = Object.entries(meta)
|
|
168
|
+
.map(([name, content]) => ` <meta name="${name}" content="${content}">`)
|
|
169
|
+
.join('\n');
|
|
170
|
+
|
|
171
|
+
// Build style links
|
|
172
|
+
const styleLinks = styles
|
|
173
|
+
.map(href => ` <link rel="stylesheet" href="${href}">`)
|
|
174
|
+
.join('\n');
|
|
175
|
+
|
|
176
|
+
// Build script tags
|
|
177
|
+
const scriptTags = scripts
|
|
178
|
+
.map(src => ` <script src="${src}"></script>`)
|
|
179
|
+
.join('\n');
|
|
180
|
+
|
|
181
|
+
// Build hydration script (passes initial props to client React)
|
|
182
|
+
let hydrationScript = '';
|
|
183
|
+
if (hydrate) {
|
|
184
|
+
const safeProps = JSON.stringify(props)
|
|
185
|
+
.replace(/</g, '\\u003c')
|
|
186
|
+
.replace(/>/g, '\\u003e')
|
|
187
|
+
.replace(/&/g, '\\u0026');
|
|
188
|
+
hydrationScript = `
|
|
189
|
+
<script>
|
|
190
|
+
window.__VOLT_INITIAL_STATE__ = ${safeProps};
|
|
191
|
+
window.__VOLT_COMPONENT__ = "${componentName}";
|
|
192
|
+
</script>`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Build the full document
|
|
196
|
+
const doc = `${this._doctype ? '<!DOCTYPE html>\n' : ''}<html lang="${lang}">
|
|
197
|
+
<head>
|
|
198
|
+
<meta charset="UTF-8">
|
|
199
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
200
|
+
<title>${this._escapeHtml(title)}</title>
|
|
201
|
+
${metaTags}${styleLinks}${inlineStyles ? `\n <style>\n${inlineStyles}\n </style>` : ''}${headHtml ? `\n${headHtml}` : ''}
|
|
202
|
+
</head>
|
|
203
|
+
<body>
|
|
204
|
+
<div id="${containerId}">${bodyHtml}</div>${hydrationScript}
|
|
205
|
+
${scriptTags}
|
|
206
|
+
</body>
|
|
207
|
+
</html>`;
|
|
208
|
+
|
|
209
|
+
return doc;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Render a React element to a stream (for large pages)
|
|
214
|
+
* @param {React.Element} element - React element
|
|
215
|
+
* @returns {NodeJS.ReadableStream}
|
|
216
|
+
*/
|
|
217
|
+
renderToStream(element) {
|
|
218
|
+
if (ReactDOMServer.renderToPipeableStream) {
|
|
219
|
+
// React 18+
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const { pipe, abort } = ReactDOMServer.renderToPipeableStream(element, {
|
|
222
|
+
onShellReady() { resolve({ pipe, abort }); },
|
|
223
|
+
onError(err) { reject(err); },
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
// Fallback: renderToNodeStream (deprecated but available)
|
|
228
|
+
if (ReactDOMServer.renderToNodeStream) {
|
|
229
|
+
return ReactDOMServer.renderToNodeStream(element);
|
|
230
|
+
}
|
|
231
|
+
throw new Error('Streaming SSR not available in this React version.');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Escape HTML entities */
|
|
235
|
+
_escapeHtml(str) {
|
|
236
|
+
return String(str)
|
|
237
|
+
.replace(/&/g, '&')
|
|
238
|
+
.replace(/</g, '<')
|
|
239
|
+
.replace(/>/g, '>')
|
|
240
|
+
.replace(/"/g, '"');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = { ReactRenderer };
|