nibula 1.2.3 → 1.2.5

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.
Files changed (106) hide show
  1. package/.eleventy.js +81 -81
  2. package/.eleventyignore +3 -3
  3. package/.github/workflows/publish.yml +37 -0
  4. package/CHANGELOG.md +166 -135
  5. package/LICENSE +169 -169
  6. package/NOTICE +4 -4
  7. package/README.md +120 -123
  8. package/bin/create.js +507 -507
  9. package/bin/nibula.js +316 -307
  10. package/docs/Assistant CLI.md +65 -65
  11. package/docs/Backend.md +295 -295
  12. package/docs/Components.md +84 -84
  13. package/docs/Creating pages.md +64 -64
  14. package/docs/Deploy.md +189 -189
  15. package/docs/Head and SEO.md +138 -138
  16. package/docs/Javascript.md +50 -50
  17. package/docs/Styling with SCSS.md +141 -141
  18. package/nginx.conf +75 -75
  19. package/package.json +1 -1
  20. package/src/backend/.htaccess +6 -6
  21. package/src/backend/_core/composer.json +5 -5
  22. package/src/backend/_core/composer.lock +492 -492
  23. package/src/backend/_core/index.js +267 -267
  24. package/src/backend/_core/index.php +147 -147
  25. package/src/backend/_core/init.js +52 -52
  26. package/src/backend/_core/init.php +33 -33
  27. package/src/backend/_core/modules/RateLimiter.js +58 -58
  28. package/src/backend/_core/modules/RateLimiter.php +30 -30
  29. package/src/backend/_core/modules/Response.js +59 -59
  30. package/src/backend/_core/modules/Response.php +48 -48
  31. package/src/backend/api/protected/example-protected.js +23 -23
  32. package/src/backend/api/protected/example-protected.php +16 -16
  33. package/src/backend/api/public/example-public.js +24 -24
  34. package/src/backend/api/public/example-public.php +16 -16
  35. package/src/backend/backend-node.service.example +30 -30
  36. package/src/backend/database/Database.js +46 -46
  37. package/src/backend/database/Database.php +23 -23
  38. package/src/backend/example.config.js +37 -37
  39. package/src/backend/example.config.php +27 -27
  40. package/src/backend/package.json +18 -18
  41. package/src/backend/web.config +16 -16
  42. package/src/frontend/.htaccess +51 -51
  43. package/src/frontend/404.njk +17 -17
  44. package/src/frontend/assets/brand/favicon.svg +54 -54
  45. package/src/frontend/assets/brand/logo.svg +54 -54
  46. package/src/frontend/components/global/footer.njk +25 -25
  47. package/src/frontend/components/global/header.njk +6 -6
  48. package/src/frontend/components/welcome.njk +114 -114
  49. package/src/frontend/data/site.json +48 -48
  50. package/src/frontend/index.njk +8 -8
  51. package/src/frontend/js/modules/exampleModule.js +2 -2
  52. package/src/frontend/js/pages/404.js +6 -6
  53. package/src/frontend/js/pages/homepage.js +6 -6
  54. package/src/frontend/layouts/base.njk +132 -132
  55. package/src/frontend/layouts/page-components.njk +13 -13
  56. package/src/frontend/llms.njk +17 -17
  57. package/src/frontend/robots.njk +7 -7
  58. package/src/frontend/scss/modules/_animations.scss +24 -24
  59. package/src/frontend/scss/modules/_footer.scss +27 -27
  60. package/src/frontend/scss/modules/_global.scss +47 -47
  61. package/src/frontend/scss/modules/_header.scss +27 -27
  62. package/src/frontend/scss/modules/_mobile.scss +29 -29
  63. package/src/frontend/scss/modules/_root.scss +34 -34
  64. package/src/frontend/scss/modules/_typography.scss +14 -14
  65. package/src/frontend/scss/modules/frameworks/_bootstrap.scss +109 -109
  66. package/src/frontend/scss/modules/frameworks/_bulma.scss +108 -108
  67. package/src/frontend/scss/modules/frameworks/_foundation.scss +138 -139
  68. package/src/frontend/scss/modules/frameworks/_uikit.scss +109 -109
  69. package/src/frontend/scss/pages/404.scss +27 -27
  70. package/src/frontend/scss/pages/homepage.scss +22 -22
  71. package/src/frontend/sitemap.njk +17 -17
  72. package/src/frontend/ts/modules/exampleModule.ts +2 -2
  73. package/src/frontend/ts/pages/404.ts +6 -6
  74. package/src/frontend/ts/pages/homepage.ts +6 -6
  75. package/src/frontend/web.config +55 -55
  76. package/tools/assistant.js +122 -151
  77. package/tools/buildJs.js +39 -37
  78. package/tools/cleanOutput.js +23 -25
  79. package/tools/cli/prompt.js +40 -0
  80. package/tools/cli/ui.js +74 -0
  81. package/tools/config/messages.json +78 -0
  82. package/tools/config/settings.json +135 -0
  83. package/tools/lib/colors.js +17 -0
  84. package/tools/lib/files.js +65 -0
  85. package/tools/lib/logger.js +22 -0
  86. package/tools/lib/outputPath.js +141 -0
  87. package/tools/lib/pageActions.js +138 -0
  88. package/tools/lib/pageArtifacts.js +46 -0
  89. package/tools/lib/pageComponents.js +88 -0
  90. package/tools/lib/paths.js +61 -0
  91. package/tools/lib/project.js +54 -0
  92. package/tools/lib/siteData.js +93 -0
  93. package/tools/lib/text.js +47 -0
  94. package/tools/lib/validation.js +39 -0
  95. package/tools/res/templates/template.js +7 -5
  96. package/tools/res/templates/template.njk +8 -8
  97. package/tools/res/templates/template.scss +22 -22
  98. package/tools/res/templates/template.ts +7 -5
  99. package/tsconfig.json +24 -24
  100. package/tools/modules/constants.js +0 -66
  101. package/tools/modules/pageComponents.js +0 -77
  102. package/tools/modules/updateData.js +0 -90
  103. package/tools/modules/updateOutputPath.js +0 -133
  104. package/tools/modules/updatePage.js +0 -162
  105. package/tools/modules/utils.js +0 -27
  106. package/tools/modules/validation.js +0 -30
@@ -1,267 +1,267 @@
1
- 'use strict';
2
-
3
- /**
4
- * Mirror of src/backend/_core/index.php (the front controller).
5
- *
6
- * PHP was invoked once per request by the web server through PHP-FPM. Node is
7
- * the server itself, so this file boots an HTTP listener and runs the exact same
8
- * pipeline for every request:
9
- *
10
- * 1. request parsing (strip /api prefix, split segments, reject . / ..)
11
- * 2. endpoint resolution (dynamic routing with subdirectories + route params)
12
- * 3. headers + CORS
13
- * 4. rate limiting
14
- * 5. authentication guard for protected endpoints
15
- * 6. dispatch to the matched endpoint file
16
- *
17
- * Endpoints keep the same authoring style as PHP: a file that receives
18
- * { method, requestParams, Response, ... } and calls Response.success/error.
19
- */
20
-
21
- const http = require('http');
22
- const fs = require('fs');
23
- const path = require('path');
24
- const crypto = require('crypto');
25
-
26
- const { createResponse, HALT } = require('./modules/Response');
27
- const RateLimiter = require('./modules/RateLimiter');
28
- const { loadConfig, handleException } = require('./init');
29
-
30
- const CORE_PATH = __dirname;
31
-
32
- // Load configuration once (init.php loaded it per invocation; here per process).
33
- const config = loadConfig();
34
-
35
- // Environment: mirror the display_errors toggle by picking the debug flag.
36
- const APP_ENV = config.APP_ENV || 'production';
37
-
38
- // Where to look for the 404.html generated by the static build.
39
- // In a Nibula build the backend lives at out/backend/, so the site root (out/)
40
- // is two levels up from _core. We default to that, so 404.html is found without
41
- // any extra configuration; override with the DOCUMENT_ROOT env var if needed.
42
- // (equivalent of $_SERVER['DOCUMENT_ROOT'] in the PHP front controller).
43
- const DOCUMENT_ROOT = process.env.DOCUMENT_ROOT || path.join(CORE_PATH, '..', '..');
44
-
45
- const basePublic = path.join(CORE_PATH, '..', 'api', 'public');
46
- const baseProtected = path.join(CORE_PATH, '..', 'api', 'protected');
47
-
48
- /**
49
- * hash_equals equivalent: constant-time string comparison, safe on unequal
50
- * lengths (timingSafeEqual throws if buffers differ in length).
51
- */
52
- function hashEquals(known, given) {
53
- const a = Buffer.from(String(known));
54
- const b = Buffer.from(String(given));
55
- if (a.length !== b.length) {
56
- return false;
57
- }
58
- return crypto.timingSafeEqual(a, b);
59
- }
60
-
61
- /** Serve the static 404 page if available, else a plain fallback. */
62
- function send404(res) {
63
- res.statusCode = 404;
64
- const errorPage = DOCUMENT_ROOT ? path.join(DOCUMENT_ROOT, '404.html') : '';
65
- if (errorPage && fs.existsSync(errorPage)) {
66
- res.setHeader('Content-Type', 'text/html; charset=UTF-8');
67
- res.end(fs.readFileSync(errorPage));
68
- } else {
69
- res.setHeader('Content-Type', 'text/html; charset=UTF-8');
70
- res.end('<h1>404 Not Found</h1>The requested URL was not found on this server.');
71
- }
72
- }
73
-
74
- /** Read and JSON-parse the request body (available to endpoints as ctx.body). */
75
- function readBody(req) {
76
- return new Promise((resolve) => {
77
- const chunks = [];
78
- req.on('data', (c) => chunks.push(c));
79
- req.on('end', () => {
80
- const raw = Buffer.concat(chunks).toString('utf8');
81
- if (!raw) return resolve({ raw: '', json: null });
82
- let json = null;
83
- try { json = JSON.parse(raw); } catch (e) { json = null; }
84
- resolve({ raw, json });
85
- });
86
- req.on('error', () => resolve({ raw: '', json: null }));
87
- });
88
- }
89
-
90
- async function handleRequest(req, res) {
91
- const Response = createResponse(res);
92
-
93
- try {
94
- // =====================================================
95
- // 1. REQUEST PARSING
96
- // =====================================================
97
- const method = req.method;
98
- const parsedUrl = new URL(req.url, 'http://localhost');
99
- let uri = parsedUrl.pathname;
100
-
101
- // Strip a leading /api, then trim trailing slash (rtrim(..., '/') ?: '/').
102
- uri = uri.replace(/^\/api/, '');
103
- uri = uri.replace(/\/+$/, '');
104
- if (uri === '') uri = '/';
105
-
106
- const parts = uri.split('/').filter((p) => p !== '');
107
-
108
- for (const part of parts) {
109
- if (part === '..' || part === '.') {
110
- Response.error('Bad Request', 400);
111
- }
112
- }
113
-
114
- // =====================================================
115
- // 2. ENDPOINT RESOLUTION (ROUTING WITH SUBDIRECTORIES)
116
- // =====================================================
117
- let endpointFile = null;
118
- let isProtected = false;
119
-
120
- const checkParts = parts.slice();
121
- const params = [];
122
-
123
- while (checkParts.length > 0) {
124
- const relativePath = checkParts.join('/') + '.js';
125
-
126
- const publicCandidate = path.join(basePublic, relativePath);
127
- if (fs.existsSync(publicCandidate)) {
128
- endpointFile = publicCandidate;
129
- isProtected = false;
130
- break;
131
- }
132
-
133
- const protectedCandidate = path.join(baseProtected, relativePath);
134
- if (fs.existsSync(protectedCandidate)) {
135
- endpointFile = protectedCandidate;
136
- isProtected = true;
137
- break;
138
- }
139
-
140
- // No match: peel the last segment as a route param and retry.
141
- params.unshift(checkParts.pop());
142
- }
143
-
144
- // No endpoint → HTML 404 page.
145
- if (!endpointFile) {
146
- send404(res);
147
- return;
148
- }
149
-
150
- // Compute the endpoint path (relative, without extension) used as the key
151
- // for CUSTOM_ENDPOINT_KEYS / CUSTOM_ENDPOINT_ORIGINS.
152
- const base = isProtected ? baseProtected : basePublic;
153
- let endpointPath = endpointFile
154
- .split(path.sep).join('/')
155
- .replace(base.split(path.sep).join('/'), '')
156
- .replace(/\.js$/, '')
157
- .replace(/^\//, '');
158
-
159
- // =====================================================
160
- // 3. HEADERS AND CORS (only if the endpoint exists)
161
- // =====================================================
162
- res.setHeader('Content-Type', 'application/json; charset=UTF-8');
163
- res.setHeader('X-Content-Type-Options', 'nosniff');
164
- res.setHeader('X-Frame-Options', 'DENY');
165
- res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
166
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
167
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key');
168
-
169
- const originsForEndpoint =
170
- (config.CUSTOM_ENDPOINT_ORIGINS && config.CUSTOM_ENDPOINT_ORIGINS[endpointPath]) ||
171
- config.GENERAL_ALLOWED_ORIGINS ||
172
- [];
173
-
174
- const origin = req.headers['origin'] || '';
175
-
176
- if (origin !== '' && originsForEndpoint.includes(origin)) {
177
- res.setHeader('Access-Control-Allow-Origin', origin);
178
- res.setHeader('Vary', 'Origin');
179
- } else if (originsForEndpoint.includes('*')) {
180
- res.setHeader('Access-Control-Allow-Origin', '*');
181
- }
182
-
183
- if (method === 'OPTIONS') {
184
- res.statusCode = 204;
185
- res.end();
186
- return;
187
- }
188
-
189
- // Rate limit: 60 requests / 60 seconds per IP (same defaults as PHP).
190
- const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
191
- || req.socket.remoteAddress
192
- || '';
193
- RateLimiter.check(ip, 60, 60, Response, res);
194
-
195
- // =====================================================
196
- // 4. AUTHENTICATION GUARD
197
- // =====================================================
198
- if (isProtected) {
199
- const validKey =
200
- (config.CUSTOM_ENDPOINT_KEYS && config.CUSTOM_ENDPOINT_KEYS[endpointPath]) ||
201
- config.GENERAL_API_KEY ||
202
- '';
203
- const apiKey = req.headers['x-api-key'] || '';
204
-
205
- if (validKey === '' || !hashEquals(validKey, apiKey)) {
206
- Response.error('Unauthorized. X_API_KEY is incorrect or missing', 401);
207
- }
208
- }
209
-
210
- // =====================================================
211
- // 5. DISPATCH
212
- // =====================================================
213
- const body = await readBody(req);
214
-
215
- // eslint-disable-next-line import/no-dynamic-require, global-require
216
- const handler = require(endpointFile);
217
-
218
- const context = {
219
- method, // HTTP method
220
- requestParams: params, // leftover URL segments (route params)
221
- Response, // request-bound Response helper
222
- query: Object.fromEntries(parsedUrl.searchParams), // parsed query string
223
- body: body.json, // parsed JSON body (or null)
224
- rawBody: body.raw, // raw body string
225
- headers: req.headers,
226
- req,
227
- res,
228
- config,
229
- CORE_PATH,
230
- };
231
-
232
- await handler(context);
233
-
234
- // If the endpoint returned without sending a response, mirror PHP's
235
- // implicit end of script: nothing more to do. Close if still open.
236
- if (!res.writableEnded) {
237
- res.end();
238
- }
239
- } catch (err) {
240
- if (err === HALT) {
241
- // A response was already sent; unwind quietly (≈ PHP exit).
242
- return;
243
- }
244
- // set_exception_handler equivalent: any real error → 500.
245
- try {
246
- handleException(err, config, Response);
247
- } catch (inner) {
248
- if (inner === HALT) return;
249
- if (!res.writableEnded) {
250
- res.statusCode = 500;
251
- res.end(JSON.stringify({ status: 'error', message: 'Internal server error', code: 500 }));
252
- }
253
- }
254
- }
255
- }
256
-
257
- const HOST = process.env.HOST || '127.0.0.1';
258
- const PORT = parseInt(process.env.PORT || '3000', 10);
259
-
260
- const server = http.createServer(handleRequest);
261
-
262
- server.listen(PORT, HOST, () => {
263
- // eslint-disable-next-line no-console
264
- console.log(`[backend-node] listening on http://${HOST}:${PORT} (env: ${APP_ENV})`);
265
- });
266
-
267
- module.exports = server;
1
+ 'use strict';
2
+
3
+ /**
4
+ * Mirror of src/backend/_core/index.php (the front controller).
5
+ *
6
+ * PHP was invoked once per request by the web server through PHP-FPM. Node is
7
+ * the server itself, so this file boots an HTTP listener and runs the exact same
8
+ * pipeline for every request:
9
+ *
10
+ * 1. request parsing (strip /api prefix, split segments, reject . / ..)
11
+ * 2. endpoint resolution (dynamic routing with subdirectories + route params)
12
+ * 3. headers + CORS
13
+ * 4. rate limiting
14
+ * 5. authentication guard for protected endpoints
15
+ * 6. dispatch to the matched endpoint file
16
+ *
17
+ * Endpoints keep the same authoring style as PHP: a file that receives
18
+ * { method, requestParams, Response, ... } and calls Response.success/error.
19
+ */
20
+
21
+ const http = require('http');
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const crypto = require('crypto');
25
+
26
+ const { createResponse, HALT } = require('./modules/Response');
27
+ const RateLimiter = require('./modules/RateLimiter');
28
+ const { loadConfig, handleException } = require('./init');
29
+
30
+ const CORE_PATH = __dirname;
31
+
32
+ // Load configuration once (init.php loaded it per invocation; here per process).
33
+ const config = loadConfig();
34
+
35
+ // Environment: mirror the display_errors toggle by picking the debug flag.
36
+ const APP_ENV = config.APP_ENV || 'production';
37
+
38
+ // Where to look for the 404.html generated by the static build.
39
+ // In a Nibula build the backend lives at out/backend/, so the site root (out/)
40
+ // is two levels up from _core. We default to that, so 404.html is found without
41
+ // any extra configuration; override with the DOCUMENT_ROOT env var if needed.
42
+ // (equivalent of $_SERVER['DOCUMENT_ROOT'] in the PHP front controller).
43
+ const DOCUMENT_ROOT = process.env.DOCUMENT_ROOT || path.join(CORE_PATH, '..', '..');
44
+
45
+ const basePublic = path.join(CORE_PATH, '..', 'api', 'public');
46
+ const baseProtected = path.join(CORE_PATH, '..', 'api', 'protected');
47
+
48
+ /**
49
+ * hash_equals equivalent: constant-time string comparison, safe on unequal
50
+ * lengths (timingSafeEqual throws if buffers differ in length).
51
+ */
52
+ function hashEquals(known, given) {
53
+ const a = Buffer.from(String(known));
54
+ const b = Buffer.from(String(given));
55
+ if (a.length !== b.length) {
56
+ return false;
57
+ }
58
+ return crypto.timingSafeEqual(a, b);
59
+ }
60
+
61
+ /** Serve the static 404 page if available, else a plain fallback. */
62
+ function send404(res) {
63
+ res.statusCode = 404;
64
+ const errorPage = DOCUMENT_ROOT ? path.join(DOCUMENT_ROOT, '404.html') : '';
65
+ if (errorPage && fs.existsSync(errorPage)) {
66
+ res.setHeader('Content-Type', 'text/html; charset=UTF-8');
67
+ res.end(fs.readFileSync(errorPage));
68
+ } else {
69
+ res.setHeader('Content-Type', 'text/html; charset=UTF-8');
70
+ res.end('<h1>404 Not Found</h1>The requested URL was not found on this server.');
71
+ }
72
+ }
73
+
74
+ /** Read and JSON-parse the request body (available to endpoints as ctx.body). */
75
+ function readBody(req) {
76
+ return new Promise((resolve) => {
77
+ const chunks = [];
78
+ req.on('data', (c) => chunks.push(c));
79
+ req.on('end', () => {
80
+ const raw = Buffer.concat(chunks).toString('utf8');
81
+ if (!raw) return resolve({ raw: '', json: null });
82
+ let json = null;
83
+ try { json = JSON.parse(raw); } catch (e) { json = null; }
84
+ resolve({ raw, json });
85
+ });
86
+ req.on('error', () => resolve({ raw: '', json: null }));
87
+ });
88
+ }
89
+
90
+ async function handleRequest(req, res) {
91
+ const Response = createResponse(res);
92
+
93
+ try {
94
+ // =====================================================
95
+ // 1. REQUEST PARSING
96
+ // =====================================================
97
+ const method = req.method;
98
+ const parsedUrl = new URL(req.url, 'http://localhost');
99
+ let uri = parsedUrl.pathname;
100
+
101
+ // Strip a leading /api, then trim trailing slash (rtrim(..., '/') ?: '/').
102
+ uri = uri.replace(/^\/api/, '');
103
+ uri = uri.replace(/\/+$/, '');
104
+ if (uri === '') uri = '/';
105
+
106
+ const parts = uri.split('/').filter((p) => p !== '');
107
+
108
+ for (const part of parts) {
109
+ if (part === '..' || part === '.') {
110
+ Response.error('Bad Request', 400);
111
+ }
112
+ }
113
+
114
+ // =====================================================
115
+ // 2. ENDPOINT RESOLUTION (ROUTING WITH SUBDIRECTORIES)
116
+ // =====================================================
117
+ let endpointFile = null;
118
+ let isProtected = false;
119
+
120
+ const checkParts = parts.slice();
121
+ const params = [];
122
+
123
+ while (checkParts.length > 0) {
124
+ const relativePath = checkParts.join('/') + '.js';
125
+
126
+ const publicCandidate = path.join(basePublic, relativePath);
127
+ if (fs.existsSync(publicCandidate)) {
128
+ endpointFile = publicCandidate;
129
+ isProtected = false;
130
+ break;
131
+ }
132
+
133
+ const protectedCandidate = path.join(baseProtected, relativePath);
134
+ if (fs.existsSync(protectedCandidate)) {
135
+ endpointFile = protectedCandidate;
136
+ isProtected = true;
137
+ break;
138
+ }
139
+
140
+ // No match: peel the last segment as a route param and retry.
141
+ params.unshift(checkParts.pop());
142
+ }
143
+
144
+ // No endpoint → HTML 404 page.
145
+ if (!endpointFile) {
146
+ send404(res);
147
+ return;
148
+ }
149
+
150
+ // Compute the endpoint path (relative, without extension) used as the key
151
+ // for CUSTOM_ENDPOINT_KEYS / CUSTOM_ENDPOINT_ORIGINS.
152
+ const base = isProtected ? baseProtected : basePublic;
153
+ let endpointPath = endpointFile
154
+ .split(path.sep).join('/')
155
+ .replace(base.split(path.sep).join('/'), '')
156
+ .replace(/\.js$/, '')
157
+ .replace(/^\//, '');
158
+
159
+ // =====================================================
160
+ // 3. HEADERS AND CORS (only if the endpoint exists)
161
+ // =====================================================
162
+ res.setHeader('Content-Type', 'application/json; charset=UTF-8');
163
+ res.setHeader('X-Content-Type-Options', 'nosniff');
164
+ res.setHeader('X-Frame-Options', 'DENY');
165
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
166
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
167
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key');
168
+
169
+ const originsForEndpoint =
170
+ (config.CUSTOM_ENDPOINT_ORIGINS && config.CUSTOM_ENDPOINT_ORIGINS[endpointPath]) ||
171
+ config.GENERAL_ALLOWED_ORIGINS ||
172
+ [];
173
+
174
+ const origin = req.headers['origin'] || '';
175
+
176
+ if (origin !== '' && originsForEndpoint.includes(origin)) {
177
+ res.setHeader('Access-Control-Allow-Origin', origin);
178
+ res.setHeader('Vary', 'Origin');
179
+ } else if (originsForEndpoint.includes('*')) {
180
+ res.setHeader('Access-Control-Allow-Origin', '*');
181
+ }
182
+
183
+ if (method === 'OPTIONS') {
184
+ res.statusCode = 204;
185
+ res.end();
186
+ return;
187
+ }
188
+
189
+ // Rate limit: 60 requests / 60 seconds per IP (same defaults as PHP).
190
+ const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
191
+ || req.socket.remoteAddress
192
+ || '';
193
+ RateLimiter.check(ip, 60, 60, Response, res);
194
+
195
+ // =====================================================
196
+ // 4. AUTHENTICATION GUARD
197
+ // =====================================================
198
+ if (isProtected) {
199
+ const validKey =
200
+ (config.CUSTOM_ENDPOINT_KEYS && config.CUSTOM_ENDPOINT_KEYS[endpointPath]) ||
201
+ config.GENERAL_API_KEY ||
202
+ '';
203
+ const apiKey = req.headers['x-api-key'] || '';
204
+
205
+ if (validKey === '' || !hashEquals(validKey, apiKey)) {
206
+ Response.error('Unauthorized. X_API_KEY is incorrect or missing', 401);
207
+ }
208
+ }
209
+
210
+ // =====================================================
211
+ // 5. DISPATCH
212
+ // =====================================================
213
+ const body = await readBody(req);
214
+
215
+ // eslint-disable-next-line import/no-dynamic-require, global-require
216
+ const handler = require(endpointFile);
217
+
218
+ const context = {
219
+ method, // HTTP method
220
+ requestParams: params, // leftover URL segments (route params)
221
+ Response, // request-bound Response helper
222
+ query: Object.fromEntries(parsedUrl.searchParams), // parsed query string
223
+ body: body.json, // parsed JSON body (or null)
224
+ rawBody: body.raw, // raw body string
225
+ headers: req.headers,
226
+ req,
227
+ res,
228
+ config,
229
+ CORE_PATH,
230
+ };
231
+
232
+ await handler(context);
233
+
234
+ // If the endpoint returned without sending a response, mirror PHP's
235
+ // implicit end of script: nothing more to do. Close if still open.
236
+ if (!res.writableEnded) {
237
+ res.end();
238
+ }
239
+ } catch (err) {
240
+ if (err === HALT) {
241
+ // A response was already sent; unwind quietly (≈ PHP exit).
242
+ return;
243
+ }
244
+ // set_exception_handler equivalent: any real error → 500.
245
+ try {
246
+ handleException(err, config, Response);
247
+ } catch (inner) {
248
+ if (inner === HALT) return;
249
+ if (!res.writableEnded) {
250
+ res.statusCode = 500;
251
+ res.end(JSON.stringify({ status: 'error', message: 'Internal server error', code: 500 }));
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ const HOST = process.env.HOST || '127.0.0.1';
258
+ const PORT = parseInt(process.env.PORT || '3000', 10);
259
+
260
+ const server = http.createServer(handleRequest);
261
+
262
+ server.listen(PORT, HOST, () => {
263
+ // eslint-disable-next-line no-console
264
+ console.log(`[backend-node] listening on http://${HOST}:${PORT} (env: ${APP_ENV})`);
265
+ });
266
+
267
+ module.exports = server;