fullstx 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/README.md +86 -0
- package/bin/create.js +68 -0
- package/bin/generate.js +78 -0
- package/coverage/clover.xml +1356 -0
- package/coverage/coverage-final.json +30 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/fullstx/index.html +146 -0
- package/coverage/lcov-report/fullstx/index.js.html +379 -0
- package/coverage/lcov-report/fullstx/lib/application.js.html +1840 -0
- package/coverage/lcov-report/fullstx/lib/browser-sync.js.html +199 -0
- package/coverage/lcov-report/fullstx/lib/database.js.html +535 -0
- package/coverage/lcov-report/fullstx/lib/errors.js.html +154 -0
- package/coverage/lcov-report/fullstx/lib/features/hooks.js.html +151 -0
- package/coverage/lcov-report/fullstx/lib/features/index.html +176 -0
- package/coverage/lcov-report/fullstx/lib/features/jwt.js.html +214 -0
- package/coverage/lcov-report/fullstx/lib/features/plugins.js.html +172 -0
- package/coverage/lcov-report/fullstx/lib/features/routing.js.html +211 -0
- package/coverage/lcov-report/fullstx/lib/features/utils.js.html +1216 -0
- package/coverage/lcov-report/fullstx/lib/index.html +236 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/apiKey.js.html +127 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/authorize.js.html +115 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/basicAuth.js.html +148 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/csp.js.html +124 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/csrf.js.html +178 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/htmx.js.html +157 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/i18n.js.html +175 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/index.html +281 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/rateLimit.js.html +169 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/redisCache.js.html +154 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/requirePermission.js.html +118 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/routeLocalization.js.html +118 -0
- package/coverage/lcov-report/fullstx/lib/middlewares/session.js.html +133 -0
- package/coverage/lcov-report/fullstx/lib/middlewares.js.html +340 -0
- package/coverage/lcov-report/fullstx/lib/radix-router.js.html +418 -0
- package/coverage/lcov-report/fullstx/lib/request.js.html +328 -0
- package/coverage/lcov-report/fullstx/lib/response.js.html +610 -0
- package/coverage/lcov-report/fullstx/lib/websocket.js.html +166 -0
- package/coverage/lcov-report/fullstx/router.js.html +226 -0
- package/coverage/lcov-report/fullstx/server.js.html +664 -0
- package/coverage/lcov-report/index.html +161 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +2753 -0
- package/dist/index.js +238 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +238 -0
- package/dist/index.mjs.map +1 -0
- package/index.d.ts +115 -0
- package/index.js +98 -0
- package/lib/application.js +585 -0
- package/lib/browser-sync.js +38 -0
- package/lib/database.js +150 -0
- package/lib/errors.js +23 -0
- package/lib/features/hooks.js +22 -0
- package/lib/features/jwt.js +43 -0
- package/lib/features/plugins.js +29 -0
- package/lib/features/routing.js +42 -0
- package/lib/features/utils.js +377 -0
- package/lib/middlewares/apiKey.js +14 -0
- package/lib/middlewares/authorize.js +10 -0
- package/lib/middlewares/basicAuth.js +21 -0
- package/lib/middlewares/csp.js +13 -0
- package/lib/middlewares/csrf.js +31 -0
- package/lib/middlewares/htmx.js +24 -0
- package/lib/middlewares/i18n.js +30 -0
- package/lib/middlewares/rateLimit.js +28 -0
- package/lib/middlewares/redisCache.js +23 -0
- package/lib/middlewares/requirePermission.js +11 -0
- package/lib/middlewares/routeLocalization.js +11 -0
- package/lib/middlewares/session.js +16 -0
- package/lib/middlewares.js +85 -0
- package/lib/radix-router.js +111 -0
- package/lib/request.js +81 -0
- package/lib/response.js +175 -0
- package/lib/websocket.js +27 -0
- package/package.json +61 -0
- package/router.js +47 -0
- package/server.js +193 -0
- package/session.js +109 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
const EventEmitter = require('events');
|
|
2
|
+
const setupRouting = require('./features/routing');
|
|
3
|
+
const setupHooks = require('./features/hooks');
|
|
4
|
+
const setupJwt = require('./features/jwt');
|
|
5
|
+
const setupPlugins = require('./features/plugins');
|
|
6
|
+
|
|
7
|
+
class FullstxApplication extends EventEmitter {
|
|
8
|
+
constructor(opts, router, db, wsRedisPub, wsRedisSub, wss, Sentry, faker, sanitizeHtml, server) {
|
|
9
|
+
super();
|
|
10
|
+
this.opts = opts;
|
|
11
|
+
this.router = router;
|
|
12
|
+
this.db = db;
|
|
13
|
+
this.wsRedisPub = wsRedisPub;
|
|
14
|
+
this.wsRedisSub = wsRedisSub;
|
|
15
|
+
this.wss = wss;
|
|
16
|
+
this.sentry = Sentry;
|
|
17
|
+
this.faker = faker;
|
|
18
|
+
this.sanitizeHtml = sanitizeHtml;
|
|
19
|
+
this.server = server;
|
|
20
|
+
|
|
21
|
+
this.get = router.get.bind(router);
|
|
22
|
+
this.post = router.post.bind(router);
|
|
23
|
+
this.put = router.put.bind(router);
|
|
24
|
+
this.patch = router.patch.bind(router);
|
|
25
|
+
this.delete = router.del.bind(router);
|
|
26
|
+
this.use = router.use.bind(router);
|
|
27
|
+
|
|
28
|
+
this.zod = require('zod');
|
|
29
|
+
|
|
30
|
+
this.plugins = new Set();
|
|
31
|
+
this.hooks = {
|
|
32
|
+
onRequest: [],
|
|
33
|
+
preValidation: [],
|
|
34
|
+
preHandler: [],
|
|
35
|
+
onResponse: []
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
setupRouting(this, this.router);
|
|
39
|
+
setupHooks(this);
|
|
40
|
+
setupJwt(this, this.opts);
|
|
41
|
+
setupPlugins(this);
|
|
42
|
+
|
|
43
|
+
const setupUtils = require('./features/utils');
|
|
44
|
+
setupUtils(this);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getServer() {
|
|
48
|
+
return this.server;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
dbPoolStats() {
|
|
52
|
+
if (!this.db) return { error: 'Database is not configured' };
|
|
53
|
+
if (this.opts.db && this.opts.db.type === 'postgres') {
|
|
54
|
+
return {
|
|
55
|
+
totalCount: this.db.totalCount,
|
|
56
|
+
idleCount: this.db.idleCount,
|
|
57
|
+
waitingCount: this.db.waitingCount
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (this.opts.db && (this.opts.db.type === 'mysql' || this.opts.db.type === 'mariadb')) {
|
|
61
|
+
const pool = this.db.pool || this.db;
|
|
62
|
+
const internalPool = pool.pool || pool;
|
|
63
|
+
if (internalPool && internalPool._allConnections) {
|
|
64
|
+
return {
|
|
65
|
+
totalCount: internalPool._allConnections.length,
|
|
66
|
+
idleCount: internalPool._freeConnections.length,
|
|
67
|
+
waitingCount: internalPool._connectionQueue ? internalPool._connectionQueue.length : 0
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { message: 'Pool stats not supported for this database type' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
sanitize(html, options) {
|
|
75
|
+
if (!this.sanitizeHtml) throw new Error('Please install sanitize-html to use sanitize()');
|
|
76
|
+
return this.sanitizeHtml(html, options);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
metaTags(tags = {}) {
|
|
80
|
+
let html = '';
|
|
81
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
82
|
+
if (!value) continue;
|
|
83
|
+
if (key === 'title') {
|
|
84
|
+
html += `<title>${value}</title>\n`;
|
|
85
|
+
} else if (key.startsWith('og:') || key.startsWith('twitter:')) {
|
|
86
|
+
html += `<meta property="${key}" content="${value}">\n`;
|
|
87
|
+
} else {
|
|
88
|
+
html += `<meta name="${key}" content="${value}">\n`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return html;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
wsBroadcast(data) {
|
|
95
|
+
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
|
96
|
+
if (this.wsRedisPub && this.wsRedisPub.isOpen) {
|
|
97
|
+
const channel = this.opts.ws.pubSubChannel || 'fullstx_ws_pubsub';
|
|
98
|
+
this.wsRedisPub.publish(channel, payload).catch(console.error);
|
|
99
|
+
} else if (this.wss) {
|
|
100
|
+
this.wss.clients.forEach((client) => {
|
|
101
|
+
if (client.readyState === 1) client.send(payload);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
factory(count, generatorFn) {
|
|
107
|
+
if (!this.faker) throw new Error('Please install @faker-js/faker to use factory()');
|
|
108
|
+
const items = [];
|
|
109
|
+
for (let i = 0; i < count; i++) {
|
|
110
|
+
items.push(generatorFn(this.faker, i));
|
|
111
|
+
}
|
|
112
|
+
return items;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
storage(provider, config) {
|
|
116
|
+
if (provider === 's3') {
|
|
117
|
+
try {
|
|
118
|
+
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
|
|
119
|
+
const client = new S3Client(config);
|
|
120
|
+
return {
|
|
121
|
+
async upload(bucket, key, body) {
|
|
122
|
+
const command = new PutObjectCommand({ Bucket: bucket, Key: key, Body: body });
|
|
123
|
+
return client.send(command);
|
|
124
|
+
},
|
|
125
|
+
async get(bucket, key) {
|
|
126
|
+
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
|
127
|
+
return client.send(command);
|
|
128
|
+
},
|
|
129
|
+
client
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
console.error("[!] Please install '@aws-sdk/client-s3' to use S3 storage.");
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
} else if (provider === 'gcs') {
|
|
136
|
+
try {
|
|
137
|
+
const { Storage } = require('@google-cloud/storage');
|
|
138
|
+
const storage = new Storage(config);
|
|
139
|
+
return {
|
|
140
|
+
async upload(bucket, key, fileBuffer) {
|
|
141
|
+
const b = storage.bucket(bucket);
|
|
142
|
+
const file = b.file(key);
|
|
143
|
+
return file.save(fileBuffer);
|
|
144
|
+
},
|
|
145
|
+
async get(bucket, key) {
|
|
146
|
+
const b = storage.bucket(bucket);
|
|
147
|
+
const file = b.file(key);
|
|
148
|
+
return file.download();
|
|
149
|
+
},
|
|
150
|
+
client: storage
|
|
151
|
+
};
|
|
152
|
+
} catch {
|
|
153
|
+
console.error("[!] Please install '@google-cloud/storage' to use GCS storage.");
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`Unsupported storage provider: ${provider}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
telemetry(serviceName) {
|
|
161
|
+
try {
|
|
162
|
+
const { NodeSDK } = require('@opentelemetry/sdk-node');
|
|
163
|
+
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
|
|
164
|
+
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-node');
|
|
165
|
+
|
|
166
|
+
const sdk = new NodeSDK({
|
|
167
|
+
serviceName: serviceName || 'fullstx-app',
|
|
168
|
+
traceExporter: new ConsoleSpanExporter(),
|
|
169
|
+
instrumentations: [getNodeAutoInstrumentations()]
|
|
170
|
+
});
|
|
171
|
+
sdk.start();
|
|
172
|
+
console.log(`[fullstx] OpenTelemetry initialized for service: ${serviceName || 'fullstx-app'}`);
|
|
173
|
+
|
|
174
|
+
process.on('SIGTERM', () => {
|
|
175
|
+
sdk.shutdown()
|
|
176
|
+
.then(() => console.log('Tracing terminated'))
|
|
177
|
+
.catch(console.error);
|
|
178
|
+
});
|
|
179
|
+
return sdk;
|
|
180
|
+
} catch {
|
|
181
|
+
console.error(
|
|
182
|
+
'[!] Please install @opentelemetry/sdk-node, @opentelemetry/auto-instrumentations-node, and @opentelemetry/sdk-trace-node'
|
|
183
|
+
);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
repl() {
|
|
189
|
+
const repl = require('repl');
|
|
190
|
+
console.log('[fullstx] Starting Interactive REPL...');
|
|
191
|
+
const replServer = repl.start({ prompt: 'fullstx> ' });
|
|
192
|
+
replServer.context.app = this;
|
|
193
|
+
replServer.context.db = this.db;
|
|
194
|
+
return replServer;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
imageResizer(options = { cacheDir: './cache' }) {
|
|
198
|
+
let sharp;
|
|
199
|
+
try {
|
|
200
|
+
sharp = require('sharp');
|
|
201
|
+
} catch {
|
|
202
|
+
console.error("[!] Please install 'sharp' to use imageResizer()");
|
|
203
|
+
return (req, res, next) => next();
|
|
204
|
+
}
|
|
205
|
+
const fs = require('fs');
|
|
206
|
+
const path = require('path');
|
|
207
|
+
if (!fs.existsSync(options.cacheDir)) fs.mkdirSync(options.cacheDir, { recursive: true });
|
|
208
|
+
|
|
209
|
+
return async (req, res, next) => {
|
|
210
|
+
const w = parseInt(req.query.w);
|
|
211
|
+
const h = parseInt(req.query.h);
|
|
212
|
+
if ((!w && !h) || !req.url.match(/\.(jpg|jpeg|png|webp|avif|gif)$/i)) return next();
|
|
213
|
+
|
|
214
|
+
const filePath = path.join(this.opts.staticDir, req.url.split('?')[0]);
|
|
215
|
+
if (!fs.existsSync(filePath)) return next();
|
|
216
|
+
|
|
217
|
+
const cacheKey = `${w || 'auto'}x${h || 'auto'}_${path.basename(filePath)}`;
|
|
218
|
+
const cachePath = path.join(options.cacheDir, cacheKey);
|
|
219
|
+
|
|
220
|
+
if (fs.existsSync(cachePath)) {
|
|
221
|
+
res.setHeader('Content-Type', 'image/' + path.extname(cachePath).slice(1));
|
|
222
|
+
return fs.createReadStream(cachePath).pipe(res);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const transformer = sharp(filePath).resize({ width: w || null, height: h || null, fit: 'cover' });
|
|
227
|
+
await transformer.toFile(cachePath);
|
|
228
|
+
res.setHeader('Content-Type', 'image/' + path.extname(cachePath).slice(1));
|
|
229
|
+
return fs.createReadStream(cachePath).pipe(res);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
console.error('Image resize error:', e);
|
|
232
|
+
next();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
inertia(
|
|
238
|
+
htmlTemplate = (page) => `<div id="app" data-page='${JSON.stringify(page).replace(/'/g, ''')}' ></div>`,
|
|
239
|
+
version = '1.0'
|
|
240
|
+
) {
|
|
241
|
+
return (req, res, next) => {
|
|
242
|
+
res.inertia = (component, props = {}) => {
|
|
243
|
+
const page = {
|
|
244
|
+
component,
|
|
245
|
+
props,
|
|
246
|
+
url: req.url,
|
|
247
|
+
version
|
|
248
|
+
};
|
|
249
|
+
if (req.headers['x-inertia']) {
|
|
250
|
+
res.setHeader('Vary', 'Accept');
|
|
251
|
+
res.setHeader('X-Inertia', 'true');
|
|
252
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
253
|
+
return res.end(JSON.stringify(page));
|
|
254
|
+
}
|
|
255
|
+
const html = htmlTemplate(page);
|
|
256
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
257
|
+
return res.end(html);
|
|
258
|
+
};
|
|
259
|
+
next();
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
tenant(options = { resolveBy: 'subdomain' }) {
|
|
264
|
+
return (req, res, next) => {
|
|
265
|
+
const host = req.headers.host || '';
|
|
266
|
+
if (options.resolveBy === 'subdomain') {
|
|
267
|
+
const parts = host.split('.');
|
|
268
|
+
if (parts.length > 2) {
|
|
269
|
+
req.tenant = parts[0];
|
|
270
|
+
}
|
|
271
|
+
} else if (options.resolveBy === 'header') {
|
|
272
|
+
req.tenant = req.headers['x-tenant-id'];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!req.tenant && options.requireTenant) {
|
|
276
|
+
if (res.json) return res.json({ error: 'Tenant not identified' }, 400);
|
|
277
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
278
|
+
return res.end(JSON.stringify({ error: 'Tenant not identified' }));
|
|
279
|
+
}
|
|
280
|
+
next();
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
apiDocs(endpoint = '/api/docs') {
|
|
285
|
+
this.get(endpoint, (req, res) => {
|
|
286
|
+
const docs = this.router.routes.map((r) => ({
|
|
287
|
+
method: r.method,
|
|
288
|
+
path: r.path
|
|
289
|
+
}));
|
|
290
|
+
if (res.json) return res.json({ routes: docs });
|
|
291
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
292
|
+
res.end(JSON.stringify({ routes: docs }));
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
dbReplica(writeConnection, readConnections = []) {
|
|
297
|
+
let readIndex = 0;
|
|
298
|
+
return {
|
|
299
|
+
get write() {
|
|
300
|
+
return writeConnection;
|
|
301
|
+
},
|
|
302
|
+
get read() {
|
|
303
|
+
if (readConnections.length === 0) return writeConnection;
|
|
304
|
+
const conn = readConnections[readIndex];
|
|
305
|
+
readIndex = (readIndex + 1) % readConnections.length;
|
|
306
|
+
return conn;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
presence() {
|
|
312
|
+
const channels = new Map();
|
|
313
|
+
return {
|
|
314
|
+
join(channel, user) {
|
|
315
|
+
if (!channels.has(channel)) channels.set(channel, new Set());
|
|
316
|
+
channels.get(channel).add(user);
|
|
317
|
+
return Array.from(channels.get(channel));
|
|
318
|
+
},
|
|
319
|
+
leave(channel, user) {
|
|
320
|
+
if (channels.has(channel)) {
|
|
321
|
+
channels.get(channel).delete(user);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
getUsers(channel) {
|
|
325
|
+
return channels.has(channel) ? Array.from(channels.get(channel)) : [];
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
adminPanel(endpoint = '/admin') {
|
|
331
|
+
this.get(endpoint, (req, res) => {
|
|
332
|
+
const html = `
|
|
333
|
+
<!DOCTYPE html>
|
|
334
|
+
<html>
|
|
335
|
+
<head>
|
|
336
|
+
<title>Fullstx Admin Panel</title>
|
|
337
|
+
<style>
|
|
338
|
+
body { font-family: system-ui, sans-serif; background: #f4f4f5; margin: 0; }
|
|
339
|
+
header { background: #18181b; color: white; padding: 1rem; }
|
|
340
|
+
.container { padding: 2rem; }
|
|
341
|
+
.card { background: white; padding: 1rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
342
|
+
</style>
|
|
343
|
+
</head>
|
|
344
|
+
<body>
|
|
345
|
+
<header><h2>⚡ Fullstx Admin</h2></header>
|
|
346
|
+
<div class="container">
|
|
347
|
+
<div class="card">
|
|
348
|
+
<h3>Dashboard Overview</h3>
|
|
349
|
+
<p>Welcome to the built-in admin panel.</p>
|
|
350
|
+
<ul>
|
|
351
|
+
<li>Registered Routes: ${this.router.routes ? this.router.routes.length : 0}</li>
|
|
352
|
+
<li>Plugins Loaded: ${this.plugins ? this.plugins.size : 0}</li>
|
|
353
|
+
<li>DB Connected: ${!!this.db}</li>
|
|
354
|
+
</ul>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</body>
|
|
358
|
+
</html>`;
|
|
359
|
+
if (res.html) return res.html(html);
|
|
360
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
361
|
+
res.end(html);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async e2eTest(url = 'http://localhost:3000', scriptFn) {
|
|
366
|
+
let chromium;
|
|
367
|
+
try {
|
|
368
|
+
chromium = require('playwright').chromium;
|
|
369
|
+
} catch {
|
|
370
|
+
console.error("[!] Please install 'playwright' to run E2E tests.");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
console.log(`[fullstx] Starting E2E Test on ${url}`);
|
|
374
|
+
const browser = await chromium.launch();
|
|
375
|
+
const page = await browser.newPage();
|
|
376
|
+
try {
|
|
377
|
+
await page.goto(url);
|
|
378
|
+
if (typeof scriptFn === 'function') {
|
|
379
|
+
await scriptFn(page);
|
|
380
|
+
}
|
|
381
|
+
console.log('[fullstx] E2E Test passed successfully.');
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.error('[!] E2E Test failed:', err);
|
|
384
|
+
} finally {
|
|
385
|
+
await browser.close();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async buildAssets(options = {}) {
|
|
390
|
+
let esbuild;
|
|
391
|
+
try {
|
|
392
|
+
esbuild = require('esbuild');
|
|
393
|
+
} catch {
|
|
394
|
+
console.error("[!] Please install 'esbuild' to use the asset bundler.");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const config = Object.assign(
|
|
398
|
+
{
|
|
399
|
+
entryPoints: ['public/js/app.js'],
|
|
400
|
+
bundle: true,
|
|
401
|
+
minify: true,
|
|
402
|
+
outdir: 'public/dist',
|
|
403
|
+
sourcemap: true
|
|
404
|
+
},
|
|
405
|
+
options
|
|
406
|
+
);
|
|
407
|
+
console.log('[fullstx] Bundling assets with esbuild...');
|
|
408
|
+
return await esbuild.build(config);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
registerDomain(domainName, { controller, service, repository }) {
|
|
412
|
+
if (!this.domains) this.domains = new Map();
|
|
413
|
+
this.domains.set(domainName, { controller, service, repository });
|
|
414
|
+
if (typeof controller.registerRoutes === 'function') {
|
|
415
|
+
const srv = typeof service === 'function' ? new service(repository) : service;
|
|
416
|
+
const ctrl = new controller(srv);
|
|
417
|
+
ctrl.registerRoutes(this.router);
|
|
418
|
+
}
|
|
419
|
+
console.log(`[fullstx] Domain registered: ${domainName}`);
|
|
420
|
+
return this;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
cqrs() {
|
|
424
|
+
const events = [];
|
|
425
|
+
const handlers = new Map();
|
|
426
|
+
return {
|
|
427
|
+
registerHandler(eventType, handlerFn) {
|
|
428
|
+
if (!handlers.has(eventType)) handlers.set(eventType, []);
|
|
429
|
+
handlers.get(eventType).push(handlerFn);
|
|
430
|
+
},
|
|
431
|
+
async dispatchCommand(commandType, payload) {
|
|
432
|
+
const event = { type: commandType + 'Executed', payload, timestamp: Date.now() };
|
|
433
|
+
events.push(event);
|
|
434
|
+
const funcs = handlers.get(event.type) || [];
|
|
435
|
+
for (const fn of funcs) {
|
|
436
|
+
await fn(event);
|
|
437
|
+
}
|
|
438
|
+
return event;
|
|
439
|
+
},
|
|
440
|
+
getEvents() {
|
|
441
|
+
return events;
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
validate(schema) {
|
|
447
|
+
return (req, res, next) => {
|
|
448
|
+
try {
|
|
449
|
+
const data = {
|
|
450
|
+
body: req.body,
|
|
451
|
+
query: req.query,
|
|
452
|
+
params: req.params || {}
|
|
453
|
+
};
|
|
454
|
+
const parsed = schema.parse(data);
|
|
455
|
+
if (parsed.body) req.body = parsed.body;
|
|
456
|
+
if (parsed.query) req.query = parsed.query;
|
|
457
|
+
if (parsed.params) req.params = parsed.params;
|
|
458
|
+
next();
|
|
459
|
+
} catch (e) {
|
|
460
|
+
console.error('Zod Validation Error:', e);
|
|
461
|
+
if (res.json) {
|
|
462
|
+
return res.json({ error: 'Validation failed', details: e.errors }, 400);
|
|
463
|
+
}
|
|
464
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
465
|
+
return res.end(JSON.stringify({ error: 'Validation failed', details: e.errors }));
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
knex(config) {
|
|
471
|
+
return require('knex')(config);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
mongoose(url, options) {
|
|
475
|
+
const mongoose = require('mongoose');
|
|
476
|
+
mongoose.connect(url, options);
|
|
477
|
+
return mongoose;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
listen(port, callback) {
|
|
481
|
+
const userPort = port;
|
|
482
|
+
const internalPort =
|
|
483
|
+
typeof this.opts.internalPort === 'number'
|
|
484
|
+
? this.opts.internalPort
|
|
485
|
+
: this.opts.liveReload
|
|
486
|
+
? port + (this.opts.internalPortOffset || 1000)
|
|
487
|
+
: port;
|
|
488
|
+
|
|
489
|
+
this.router.middlewares.unshift(async (req, res, next) => {
|
|
490
|
+
try {
|
|
491
|
+
if (this.executeHooks) await this.executeHooks('onRequest', req, res);
|
|
492
|
+
if (!res.headersSent) next();
|
|
493
|
+
} catch (e) {
|
|
494
|
+
if (res.json) res.json({ error: e.message }, 500);
|
|
495
|
+
else {
|
|
496
|
+
res.writeHead(500);
|
|
497
|
+
res.end(e.message);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
this.server.on('error', (err) => {
|
|
503
|
+
if (err && err.code === 'EADDRINUSE') {
|
|
504
|
+
console.error(
|
|
505
|
+
'\\n[x] Failed to start: port ' + internalPort + ' (internal) is already in use by another process.'
|
|
506
|
+
);
|
|
507
|
+
console.error(' Solution:');
|
|
508
|
+
console.error(
|
|
509
|
+
' 1) Stop the process occupying port ' +
|
|
510
|
+
internalPort +
|
|
511
|
+
' (check via: netstat -ano | grep ":' +
|
|
512
|
+
internalPort +
|
|
513
|
+
'")'
|
|
514
|
+
);
|
|
515
|
+
console.error(' 2) Or manually set internalPort: Framework({ internalPort: 4100 })\\n');
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
throw err;
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
const { WebSocketServer } = require('ws');
|
|
523
|
+
this.wss = new WebSocketServer({ server: this.server });
|
|
524
|
+
} catch {}
|
|
525
|
+
|
|
526
|
+
const handleShutdown = () => {
|
|
527
|
+
if (this.isShuttingDown) return;
|
|
528
|
+
this.isShuttingDown = true;
|
|
529
|
+
this.close();
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
process.on('SIGINT', handleShutdown);
|
|
533
|
+
process.on('SIGTERM', handleShutdown);
|
|
534
|
+
|
|
535
|
+
this.server.listen(internalPort, () => {
|
|
536
|
+
if (typeof callback === 'function') callback();
|
|
537
|
+
|
|
538
|
+
if (this.opts.liveReload) {
|
|
539
|
+
const startBrowserSync = require('./browser-sync');
|
|
540
|
+
startBrowserSync(userPort, internalPort, this.opts);
|
|
541
|
+
} else {
|
|
542
|
+
console.log('Server Running on http://localhost:' + userPort);
|
|
543
|
+
console.log('Live reload disabled (Framework({ liveReload: false }))');
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
return this.server;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
close(callback) {
|
|
551
|
+
console.log('\\n[fullstx] Initiating graceful shutdown...');
|
|
552
|
+
let pending = 1;
|
|
553
|
+
const done = () => {
|
|
554
|
+
pending--;
|
|
555
|
+
if (pending === 0) {
|
|
556
|
+
console.log('[fullstx] Server safely stopped.');
|
|
557
|
+
if (typeof callback === 'function') callback();
|
|
558
|
+
else process.exit(0);
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
if (this.wss) {
|
|
563
|
+
pending++;
|
|
564
|
+
this.wss.close(() => {
|
|
565
|
+
console.log('[fullstx] WebSocket server closed.');
|
|
566
|
+
done();
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (this.db && typeof this.db.end === 'function') {
|
|
571
|
+
pending++;
|
|
572
|
+
this.db.end(() => {
|
|
573
|
+
console.log('[fullstx] Database connection closed.');
|
|
574
|
+
done();
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
this.server.close(() => {
|
|
579
|
+
console.log('[fullstx] HTTP server closed.');
|
|
580
|
+
done();
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
module.exports = FullstxApplication;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module.exports = function startBrowserSync(userPort, internalPort, opts) {
|
|
2
|
+
let bs;
|
|
3
|
+
try {
|
|
4
|
+
bs = require('browser-sync').create();
|
|
5
|
+
} catch {
|
|
6
|
+
console.warn('[!] browser-sync is not installed, skipping hot-reload.');
|
|
7
|
+
console.log('Server Running on http://localhost:' + userPort);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const config = Object.assign(
|
|
12
|
+
{
|
|
13
|
+
port: userPort,
|
|
14
|
+
|
|
15
|
+
proxy: 'http://localhost:' + internalPort,
|
|
16
|
+
|
|
17
|
+
ui: false,
|
|
18
|
+
host: 'localhost',
|
|
19
|
+
watch: true,
|
|
20
|
+
files: opts.watch,
|
|
21
|
+
notify: false,
|
|
22
|
+
open: false,
|
|
23
|
+
reloadDelay: 100,
|
|
24
|
+
ghostMode: {
|
|
25
|
+
clicks: true,
|
|
26
|
+
scroll: true,
|
|
27
|
+
forms: true
|
|
28
|
+
},
|
|
29
|
+
logLevel: 'info'
|
|
30
|
+
},
|
|
31
|
+
opts.browserSync
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
console.log('Server Running on http://localhost:' + userPort);
|
|
35
|
+
console.log('[fullstx] Hot-reload active via browser-sync.');
|
|
36
|
+
|
|
37
|
+
bs.init(config);
|
|
38
|
+
};
|