nibula 2.4.7 → 3.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.
Files changed (51) hide show
  1. package/.eleventy.js +0 -7
  2. package/CHANGELOG.md +48 -2
  3. package/bin/create.js +43 -45
  4. package/bin/nibula.js +0 -6
  5. package/nginx.conf +1 -1
  6. package/package.json +3 -7
  7. package/src/backend/.htaccess +2 -2
  8. package/src/backend/core/index.js +329 -0
  9. package/src/backend/core/index.php +207 -0
  10. package/src/backend/core/init.js +37 -0
  11. package/src/backend/core/init.php +54 -0
  12. package/src/backend/core/modules/RateLimiter.js +55 -0
  13. package/src/backend/core/modules/RateLimiter.php +39 -0
  14. package/src/backend/{_core → core}/modules/Response.js +15 -10
  15. package/src/backend/{_core → core}/modules/Response.php +14 -17
  16. package/src/backend/core/modules/Validate.js +56 -0
  17. package/src/backend/core/modules/Validate.php +52 -0
  18. package/src/backend/endpoints/protected/example-protected.js +39 -0
  19. package/src/backend/endpoints/protected/example-protected.php +47 -0
  20. package/src/backend/endpoints/public/example-public.js +39 -0
  21. package/src/backend/endpoints/public/example-public.php +47 -0
  22. package/src/backend/example.config.js +7 -6
  23. package/src/backend/example.config.php +8 -7
  24. package/src/backend/package.json +3 -3
  25. package/src/backend/web.config +2 -2
  26. package/src/frontend/hosting/.htaccess +1 -1
  27. package/src/frontend/hosting/web.config +1 -1
  28. package/src/frontend/layouts/base.njk +3 -7
  29. package/src/frontend/scss/{_global.scss → global.scss} +0 -2
  30. package/src/frontend/scss/modules/frameworks/_bootstrap.scss +0 -2
  31. package/src/frontend/scss/modules/frameworks/_bulma.scss +0 -2
  32. package/src/frontend/scss/pages/404.scss +0 -3
  33. package/src/frontend/scss/pages/homepage.scss +0 -3
  34. package/tools/config/settings.json +1 -13
  35. package/tools/lib/pagesData.js +13 -3
  36. package/tools/res/templates/template.scss +0 -3
  37. package/src/backend/_core/index.js +0 -230
  38. package/src/backend/_core/index.php +0 -118
  39. package/src/backend/_core/init.js +0 -32
  40. package/src/backend/_core/init.php +0 -30
  41. package/src/backend/_core/modules/RateLimiter.js +0 -47
  42. package/src/backend/_core/modules/RateLimiter.php +0 -31
  43. package/src/backend/api/protected/example-protected.js +0 -16
  44. package/src/backend/api/protected/example-protected.php +0 -17
  45. package/src/backend/api/public/example-public.js +0 -16
  46. package/src/backend/api/public/example-public.php +0 -17
  47. package/src/frontend/scss/modules/frameworks/_foundation.scss +0 -138
  48. package/src/frontend/scss/modules/frameworks/_uikit.scss +0 -110
  49. package/tools/buildJs.js +0 -39
  50. /package/src/backend/{_core → core}/composer.json +0 -0
  51. /package/src/backend/{_core → core}/composer.lock +0 -0
package/.eleventy.js CHANGED
@@ -109,13 +109,6 @@ module.exports = function (eleventyConfig) {
109
109
  "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js": "js/bootstrap.bundle.min.js",
110
110
  "node_modules/bootstrap-icons/font/fonts": "css/fonts",
111
111
 
112
- // Foundation
113
- // "node_modules/foundation-sites/dist/js/foundation.min.js": "js/foundation.min.js",
114
-
115
- // UIkit
116
- // "node_modules/uikit/dist/js/uikit.min.js": "js/uikit.min.js",
117
- // "node_modules/uikit/dist/js/uikit-icons.min.js": "js/uikit-icons.min.js",
118
-
119
112
  // Bulma — CSS only, no JS passthrough needed
120
113
  });
121
114
 
package/CHANGELOG.md CHANGED
@@ -5,6 +5,52 @@ All notable changes to Nibula are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.0.0] - 2026-09-14
9
+
10
+ ### Added
11
+ - Endpoints now export one function per HTTP method instead of checking the method by hand. Unsupported methods get an automatic 405, and the file itself shows at a glance which methods it answers. The old single-function form still works.
12
+ - A `Validate` module with `required`, `integer`, `email` and `minLength`. Each rule either returns the value or stops the request with a 422 and a readable message, so endpoints no longer repeat the same checks.
13
+ - Endpoints receive `query`, `body`, `rawBody` and `headers` in both backends. The PHP side previously left the body to the endpoint, which meant every project reimplemented the same `json_decode`.
14
+ - A `Debug-Mode` header on every response when `APP_ENV` is not `production`, so a site left in debug mode is visible without reading the config.
15
+
16
+ ### Changed
17
+ - **BREAKING** Renamed `src/backend/_core` to `src/backend/core`. The underscore prefix carried no meaning: no tool interpreted it, it only made the path noisier than its siblings.
18
+ - **BREAKING** Renamed `src/backend/api` to `src/backend/endpoints`. The folder holds route files, while `/api` is the public URL prefix, and using one name for two concepts was the main source of confusion when reading the front controller. Endpoints are still reached at `domain/api/endpoint`.
19
+ - **BREAKING** A missing API key now returns 401 instead of 403. The previous code said "forbidden" for a request that was never authenticated.
20
+ - **BREAKING** The backend no longer starts without `config.js` or `config.php`. It used to fall back to the example file, which ships a publicly known API key and allows every origin.
21
+ - **BREAKING** The bundled `nginx.conf`, `.htaccess` and `web.config` now point at `backend/core` instead of `backend/_core`. Any custom server rule referring to the old path needs updating.
22
+ - **BREAKING** Removed the `build-js` command. Projects now call `esbuild` directly in their `build:js` and `serve:js` scripts, with the entry pattern written out in full. `nib` is for the commands it offers, not a wrapper around the build.
23
+ - **BREAKING** The global stylesheet is loaded by the layout instead of being imported by each page. `_global.scss` is now `global.scss`, so Sass compiles it into `out/css/global.css`, and `base.njk` links it before the page's own file. Until now every page's CSS carried a full copy of the framework and of every module, so a visitor reading three pages downloaded the same thing three times with no way to cache it.
24
+ - **BREAKING** Page stylesheets no longer start with `@import "../global"`. A new page opens with the `@use "../root"` line and nothing else, since everything shared now arrives from the layout.
25
+ - **BREAKING** Removed Foundation and UIkit from the frameworks offered at project creation. Four libraries covering the same ground made the choice harder without covering a case the other two miss, and each one carried a module file, a set of markers and a dependency in every project.
26
+ - Endpoints only receive the documented request values. They previously saw every local variable of the front controller by accident, which made internal renames a breaking change without anyone noticing.
27
+ - `RateLimiter` is now created per request like `Response`, instead of taking the response helpers as trailing arguments.
28
+ - The PHP backend removes the `X-Powered-By` header, which exposed the exact PHP version on every response.
29
+ - `Response.php` no longer carries its own access guard and 404 fallback. Every request already goes through the front controller, so the guard could never fire, and it duplicated error-page logic inside a module that only formats responses.
30
+ - Removed the `glob` dependency from new projects. It existed only to expand the entry pattern before handing it to esbuild, which expands it by itself.
31
+
32
+ ### Fixed
33
+ - A custom endpoint key or origin list set to an empty value no longer falls back to the general one.
34
+ - The PHP exception handler no longer captures the config before it exists, which used to hide the real cause of startup errors behind a generic 500.
35
+
36
+ ### Notes
37
+ - **To migrate the stylesheets:** rename `_global.scss` to `global.scss`, remove `@import "../global"` from every file in `scss/pages/`, and add the global stylesheet link to `base.njk` above the page one.
38
+ - **`nib build-js` no longer exists.** A project created before this release has it in its `package.json` and its build will fail after updating. Replace the two scripts with:
39
+
40
+ ```
41
+ "build:js": "esbuild \"src/frontend/js/pages/*.js\" --bundle --outdir=out/js/pages --minify",
42
+ "serve:js": "esbuild \"src/frontend/js/pages/*.js\" --bundle --outdir=out/js/pages --watch"
43
+ ```
44
+
45
+ A TypeScript project uses `src/frontend/ts/pages/*.ts` instead.
46
+
47
+ - **Foundation and UIkit are no longer offered.** An existing project that uses one keeps working: nothing is removed from it. A new project that wants them can install the package and add the import by hand, the same way any other library works.
48
+
49
+ ## [2.5.0] - 2026-09-07
50
+
51
+ ### Changed
52
+ - The data record a new page gets in `pages.json` comes from `tools/res/templates/template.json` instead of being written inside `settings.json`. It's content, like the route, the stylesheet and the script, and it now sits with the other three templates rather than in the CLI's configuration.
53
+
8
54
  ## [2.4.7] - 2026-09-04
9
55
 
10
56
  ### Changed
@@ -19,8 +65,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
65
 
20
66
  ### Changed
21
67
 
22
- - Restored `404.scss` **.not-found** selector to center the not found text
23
- - Removed all comments in backend files
68
+ - Restored `404.scss` **.not-found** selector to center the not found text.
69
+ - Removed all comments in backend files.
24
70
 
25
71
  ## [2.4.5] - 2026-08-26
26
72
 
package/bin/create.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const readline = require('readline');
6
+ const settings = require('../tools/config/settings.json');
6
7
  const { writeSync } = require('fs');
7
8
  const { spawnSync } = require('child_process');
8
9
  const { color } = require('../tools/lib/colors');
@@ -23,8 +24,6 @@ const LANGUAGE = Object.freeze({
23
24
  const FRAMEWORK = Object.freeze({
24
25
  BOOTSTRAP: 'bootstrap',
25
26
  BULMA: 'bulma',
26
- FOUNDATION: 'foundation',
27
- UIKIT: 'uikit',
28
27
  NONE: 'none',
29
28
  });
30
29
 
@@ -41,21 +40,19 @@ const COMMENT_STYLE = Object.freeze({
41
40
  // ── CHOICES ──────────────────────────────────────────────────────────────────
42
41
 
43
42
  const LANGUAGE_CHOICES = [
44
- { label: 'JavaScript (recommended)', value: LANGUAGE.JAVASCRIPT },
45
- { label: 'TypeScript', value: LANGUAGE.TYPESCRIPT },
43
+ { label: 'JavaScript', note: 'simpler to start', value: LANGUAGE.JAVASCRIPT },
44
+ { label: 'TypeScript', note: 'catches mistakes', value: LANGUAGE.TYPESCRIPT },
46
45
  ];
47
46
 
48
47
  const FRAMEWORK_CHOICES = [
49
- { label: 'Bootstrap (recommended)', value: FRAMEWORK.BOOTSTRAP },
50
- { label: 'Bulma', value: FRAMEWORK.BULMA },
51
- { label: 'Foundation', value: FRAMEWORK.FOUNDATION },
52
- { label: 'UIkit', value: FRAMEWORK.UIKIT },
53
- { label: 'None', value: FRAMEWORK.NONE },
48
+ { label: 'Bootstrap', note: 'the most common', value: FRAMEWORK.BOOTSTRAP },
49
+ { label: 'Bulma', note: 'lighter, no JS', value: FRAMEWORK.BULMA },
50
+ { label: 'None', value: FRAMEWORK.NONE },
54
51
  ];
55
52
 
56
53
  const BACKEND_CHOICES = [
57
- { label: 'Node.js (No composer required)', value: BACKEND.NODE },
58
- { label: 'PHP (Can run everywhere)', value: BACKEND.PHP },
54
+ { label: 'Node.js', note: 'needs a VPS', value: BACKEND.NODE },
55
+ { label: 'PHP', note: 'any hosting', value: BACKEND.PHP },
59
56
  ];
60
57
 
61
58
  // Runtime dependencies for the Node backend, read from the backend's own
@@ -109,16 +106,6 @@ const FRAMEWORKS = {
109
106
  njk: [],
110
107
  eleventy: [],
111
108
  },
112
- [FRAMEWORK.FOUNDATION]: {
113
- scss: 'foundation',
114
- njk: ['/js/foundation.min.js'],
115
- eleventy: ['foundation-sites/dist/js/foundation.min.js'],
116
- },
117
- [FRAMEWORK.UIKIT]: {
118
- scss: 'uikit',
119
- njk: ['/js/uikit.min.js', '/js/uikit-icons.min.js'],
120
- eleventy: ['uikit/dist/js/uikit.min.js', 'uikit/dist/js/uikit-icons.min.js'],
121
- },
122
109
  [FRAMEWORK.NONE]: {
123
110
  scss: null,
124
111
  njk: [],
@@ -130,7 +117,7 @@ const FRAMEWORKS = {
130
117
 
131
118
  const GITIGNORE_CONTENT = `
132
119
  node_modules/
133
- src/backend/_core/vendor/
120
+ src/backend/core/vendor/
134
121
  out/
135
122
  src/backend/config.php
136
123
  src/backend/config.js
@@ -143,18 +130,6 @@ const PROJECT_PACKAGE = {
143
130
  version: '0.0.0',
144
131
  private: true,
145
132
  outputDir: 'out',
146
- "scripts": {
147
- "build:css": "sass src/frontend/scss:out/css --no-source-map --style=compressed --quiet --load-path=node_modules",
148
- "build:js": "nib build-js",
149
- "build:11ty": "eleventy",
150
- "build": "npm run clean && npm run build:css && npm run build:js && npm run build:11ty",
151
- "serve:css": "sass --watch src/frontend/scss:out/css --no-source-map --quiet --load-path=node_modules",
152
- "serve:js": "nib build-js --watch",
153
- "serve:11ty": "eleventy --serve --quiet",
154
- "clean": "nib clean",
155
- "serve": "npm run clean && concurrently \"npm run serve:11ty\" \"npm run serve:css\" \"npm run serve:js\"",
156
- "assistant": "nib cli"
157
- },
158
133
  dependencies: {
159
134
  '@11ty/eleventy': '^3.1.6',
160
135
  '@11ty/eleventy-img': '^7.0.0',
@@ -162,11 +137,8 @@ const PROJECT_PACKAGE = {
162
137
  'bootstrap': '^5.3.8',
163
138
  'bootstrap-icons': '^1.13.1',
164
139
  'bulma': '^1.0.4',
165
- 'foundation-sites': '^6.9.0',
166
- 'glob': '^13.0.6',
167
140
  'markdown-it-anchor': '^9.2.1',
168
141
  'markdown-it-attrs': '^5.0.1',
169
- 'uikit': '^3.25.21',
170
142
  },
171
143
  devDependencies: {
172
144
  'nibula': `^${SELF_VERSION}`,
@@ -178,6 +150,24 @@ const PROJECT_PACKAGE = {
178
150
 
179
151
  // ── HELPERS ───────────────────────────────────────────────────────────────────
180
152
 
153
+ function projectScripts(language) {
154
+ const { pages, extension } = settings.languages[language];
155
+ const entries = `${pages}/*.${extension}`;
156
+
157
+ return {
158
+ 'build:css': 'sass src/frontend/scss:out/css --no-source-map --style=compressed --quiet --load-path=node_modules',
159
+ 'build:js': `esbuild "${entries}" --bundle --outdir=out/js/pages --minify`,
160
+ 'build:11ty': 'eleventy',
161
+ 'build': 'npm run clean && npm run build:css && npm run build:js && npm run build:11ty',
162
+ 'serve:css': 'sass --watch src/frontend/scss:out/css --no-source-map --quiet --load-path=node_modules',
163
+ 'serve:js': `esbuild "${entries}" --bundle --outdir=out/js/pages --watch`,
164
+ 'serve:11ty': 'eleventy --serve --quiet',
165
+ 'clean': 'nib clean',
166
+ 'serve': 'npm run clean && concurrently "npm run serve:11ty" "npm run serve:css" "npm run serve:js"',
167
+ 'assistant': 'nib cli',
168
+ };
169
+ }
170
+
181
171
  function log(msg) {
182
172
  writeSync(1, msg + '\n');
183
173
  }
@@ -300,7 +290,7 @@ function njkUncomment(content, marker) {
300
290
  }
301
291
 
302
292
  function installDependencies(backend) {
303
- const backendCore = path.join(targetDir, 'src', 'backend', '_core');
293
+ const backendCore = path.join(targetDir, 'src', 'backend', 'core');
304
294
 
305
295
  log(`${color.blue}\n>> Installing Node modules...${color.reset}`);
306
296
  const npm = spawnSync('npm', ['install'], {
@@ -335,7 +325,7 @@ function installDependencies(backend) {
335
325
 
336
326
  if (probe.status !== 0) {
337
327
  log('\n(!) Composer not found — skipping backend dependencies.');
338
- log(' Install Composer, then run: cd src/backend/_core && composer install\n');
328
+ log(' Install Composer, then run: cd src/backend/core && composer install\n');
339
329
  return true;
340
330
  }
341
331
 
@@ -354,7 +344,7 @@ function installDependencies(backend) {
354
344
  function applyFramework(framework) {
355
345
  const config = FRAMEWORKS[framework];
356
346
 
357
- const globalScssPath = path.join(targetDir, 'src/frontend/scss/_global.scss');
347
+ const globalScssPath = path.join(targetDir, 'src/frontend/scss/global.scss');
358
348
  if (fs.existsSync(globalScssPath)) {
359
349
  let content = fs.readFileSync(globalScssPath, 'utf8');
360
350
  ALL_FRAMEWORKS.forEach(fw => {
@@ -395,13 +385,20 @@ function askChoice(question, choices) {
395
385
 
396
386
  log(`\n>> ${question} (Use arrow keys and press Enter):\n`);
397
387
 
388
+ const labelWidth = Math.max(...choices.map((choice) => choice.label.length));
389
+
398
390
  const render = (firstTime = false) => {
399
391
  if (!firstTime) process.stdout.write(`\x1B[${choices.length}A`);
400
- const output = choices.map((choice, index) =>
401
- index === selectedIndex
402
- ? ` \x1b[36m◉ ${choice.label}\x1b[0m\x1B[K\n`
403
- : ` * ${choice.label}\x1B[K\n`
404
- ).join('');
392
+
393
+ const output = choices.map((choice, index) => {
394
+ const selected = index === selectedIndex;
395
+ const symbol = selected ? `${color.cyan}◉` : '*';
396
+ const label = selected ? `${choice.label}${color.reset}` : choice.label;
397
+ const note = choice.note ? `${color.dim} — ${choice.note}${color.reset}` : '';
398
+
399
+ return ` ${symbol} ${label}${note}\x1B[K\n`;
400
+ }).join('');
401
+
405
402
  process.stdout.write(output);
406
403
  };
407
404
 
@@ -478,6 +475,7 @@ async function init() {
478
475
  // Build the project package.json. Clone the shared dependency maps so we
479
476
  // never mutate the PROJECT_PACKAGE constant.
480
477
  const pkg = { ...PROJECT_PACKAGE };
478
+ pkg.scripts = projectScripts(language);
481
479
  pkg.dependencies = { ...PROJECT_PACKAGE.dependencies };
482
480
  pkg.devDependencies = { ...PROJECT_PACKAGE.devDependencies };
483
481
 
package/bin/nibula.js CHANGED
@@ -16,7 +16,6 @@ const [, , cmd, ...rest] = process.argv;
16
16
 
17
17
  const CREATE = path.join(__dirname, 'create.js');
18
18
  const ASSISTANT = path.join(__dirname, '..', 'tools', 'assistant.js');
19
- const BUILDJS = path.join(__dirname, '..', 'tools', 'buildJs.js');
20
19
  const CLEAN = path.join(__dirname, '..', 'tools', 'cleanOutput.js');
21
20
 
22
21
  const REGISTRY = 'https://registry.npmjs.org/nibula/latest';
@@ -266,11 +265,6 @@ async function main() {
266
265
  runNpm(root, 'build');
267
266
  break;
268
267
  }
269
- case 'build-js': {
270
- enterProject();
271
- run(BUILDJS, rest);
272
- break;
273
- }
274
268
  case 'clean': {
275
269
  enterProject();
276
270
  run(CLEAN, []);
package/nginx.conf CHANGED
@@ -100,7 +100,7 @@ server {
100
100
  location @php_backend {
101
101
  include fastcgi_params;
102
102
 
103
- fastcgi_param SCRIPT_FILENAME $document_root/backend/_core/index.php;
103
+ fastcgi_param SCRIPT_FILENAME $document_root/backend/core/index.php;
104
104
  fastcgi_pass unix:/run/php/php-fpm.sock;
105
105
  }
106
106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nibula",
3
- "version": "2.4.7",
3
+ "version": "3.0.0",
4
4
  "description": "A beginner-friendly static site generator built on Eleventy that stays close to vanilla HTML, CSS and JS — with a page-management CLI, an optional PHP backend, and SEO files generated for you.",
5
5
  "keywords": [
6
6
  "11ty",
@@ -21,8 +21,7 @@
21
21
  "seo-optimization",
22
22
  "ssg",
23
23
  "static-site-generator",
24
- "typescript",
25
- "uikit"
24
+ "typescript"
26
25
  ],
27
26
  "author": "Michele Garofalo",
28
27
  "license": "Apache-2.0",
@@ -62,11 +61,8 @@
62
61
  "bulma": "^1.0.4",
63
62
  "concurrently": "^9.2.4",
64
63
  "esbuild": "^0.28.2",
65
- "foundation-sites": "^6.9.0",
66
- "glob": "^13.0.6",
67
64
  "markdown-it-anchor": "^9.2.1",
68
65
  "markdown-it-attrs": "^5.0.1",
69
- "sass": "^1.103.1",
70
- "uikit": "^3.25.21"
66
+ "sass": "^1.103.1"
71
67
  }
72
68
  }
@@ -2,6 +2,6 @@
2
2
  RewriteEngine On
3
3
  RewriteBase /api/
4
4
 
5
- RewriteRule ^_core/index\.php$ - [L]
6
- RewriteRule ^(.*)$ _core/index.php [QSA,L]
5
+ RewriteRule ^core/index\.php$ - [L]
6
+ RewriteRule ^(.*)$ core/index.php [QSA,L]
7
7
  </IfModule>
@@ -0,0 +1,329 @@
1
+ "use strict";
2
+
3
+ const DEBUG_HEADER = "Debug-Mode";
4
+ const DEBUG_HEADER_VALUE = "true";
5
+
6
+ const http = require("http");
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const crypto = require("crypto");
10
+
11
+ const { createResponse, HALT } = require("./modules/Response");
12
+ const { createValidator } = require("./modules/Validate");
13
+ const { createRateLimiter } = require("./modules/RateLimiter");
14
+ const { loadConfig, isDebug, handleException } = require("./init");
15
+
16
+ const CORE_PATH = __dirname;
17
+
18
+ const URL_PREFIX = "/api";
19
+ const ENDPOINT_EXTENSION = ".js";
20
+ const ERROR_PAGE = "404.html";
21
+ const ENDPOINTS_FOLDER = "endpoints";
22
+ const PUBLIC_FOLDER = "public";
23
+ const PROTECTED_FOLDER = "protected";
24
+
25
+ const DEFAULT_HOST = "127.0.0.1";
26
+ const DEFAULT_PORT = "3000";
27
+ const RATE_LIMIT_REQUESTS = 60;
28
+ const RATE_LIMIT_WINDOW_SECONDS = 60;
29
+
30
+ const STATUS_NO_CONTENT = 204;
31
+ const STATUS_BAD_REQUEST = 400;
32
+ const STATUS_UNAUTHORIZED = 401;
33
+ const STATUS_NOT_FOUND = 404;
34
+ const STATUS_METHOD_NOT_ALLOWED = 405;
35
+ const STATUS_SERVER_ERROR = 500;
36
+
37
+ const ALLOWED_METHODS = "GET, POST, PUT, PATCH, DELETE, OPTIONS";
38
+ const ALLOWED_HEADERS = "Content-Type, X-Api-Key";
39
+ const JSON_CONTENT_TYPE = "application/json; charset=UTF-8";
40
+ const HTML_CONTENT_TYPE = "text/html; charset=UTF-8";
41
+
42
+ const config = loadConfig();
43
+ const debugMode = isDebug(config);
44
+
45
+ const DOCUMENT_ROOT =
46
+ process.env.DOCUMENT_ROOT || path.join(CORE_PATH, "..", "..");
47
+ const basePublic = path.join(CORE_PATH, "..", ENDPOINTS_FOLDER, PUBLIC_FOLDER);
48
+ const baseProtected = path.join(
49
+ CORE_PATH,
50
+ "..",
51
+ ENDPOINTS_FOLDER,
52
+ PROTECTED_FOLDER,
53
+ );
54
+
55
+ function hashEquals(known, given) {
56
+ const knownBuffer = Buffer.from(String(known));
57
+ const givenBuffer = Buffer.from(String(given));
58
+
59
+ if (knownBuffer.length !== givenBuffer.length) {
60
+ return false;
61
+ }
62
+
63
+ return crypto.timingSafeEqual(knownBuffer, givenBuffer);
64
+ }
65
+
66
+ function sendErrorPage(res) {
67
+ const errorPage = path.join(DOCUMENT_ROOT, ERROR_PAGE);
68
+
69
+ res.statusCode = STATUS_NOT_FOUND;
70
+ res.setHeader("Content-Type", HTML_CONTENT_TYPE);
71
+ res.end(
72
+ fs.existsSync(errorPage)
73
+ ? fs.readFileSync(errorPage)
74
+ : "<h1>404 Not Found</h1>The requested URL was not found on this server.",
75
+ );
76
+ }
77
+
78
+ function parseSegments(pathname) {
79
+ const uri = pathname
80
+ .replace(new RegExp(`^${URL_PREFIX}`), "")
81
+ .replace(/\/+$/, "");
82
+
83
+ return uri.split("/").filter((segment) => segment !== "");
84
+ }
85
+
86
+ function resolveEndpoint(segments) {
87
+ const remaining = segments.slice();
88
+ const params = [];
89
+
90
+ while (remaining.length > 0) {
91
+ const relativePath = remaining.join("/") + ENDPOINT_EXTENSION;
92
+
93
+ const publicCandidate = path.join(basePublic, relativePath);
94
+ if (fs.existsSync(publicCandidate)) {
95
+ return {
96
+ file: publicCandidate,
97
+ base: basePublic,
98
+ isProtected: false,
99
+ params,
100
+ };
101
+ }
102
+
103
+ const protectedCandidate = path.join(baseProtected, relativePath);
104
+ if (fs.existsSync(protectedCandidate)) {
105
+ return {
106
+ file: protectedCandidate,
107
+ base: baseProtected,
108
+ isProtected: true,
109
+ params,
110
+ };
111
+ }
112
+
113
+ params.unshift(remaining.pop());
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ function toEndpointKey(file, base) {
120
+ return file
121
+ .split(path.sep)
122
+ .join("/")
123
+ .replace(base.split(path.sep).join("/"), "")
124
+ .replace(new RegExp(`\\${ENDPOINT_EXTENSION}$`), "")
125
+ .replace(/^\//, "");
126
+ }
127
+
128
+ function applySecurityHeaders(res) {
129
+ res.setHeader("Content-Type", JSON_CONTENT_TYPE);
130
+ res.setHeader("X-Content-Type-Options", "nosniff");
131
+ res.setHeader("X-Frame-Options", "DENY");
132
+ res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
133
+ res.setHeader("Access-Control-Allow-Methods", ALLOWED_METHODS);
134
+ res.setHeader("Access-Control-Allow-Headers", ALLOWED_HEADERS);
135
+
136
+ if (debugMode) {
137
+ res.setHeader(DEBUG_HEADER, DEBUG_HEADER_VALUE);
138
+ }
139
+ }
140
+
141
+ function applyCors(res, endpointKey, origin) {
142
+ const allowedOrigins =
143
+ config.CUSTOM_ENDPOINT_ORIGINS?.[endpointKey] ??
144
+ config.GENERAL_ALLOWED_ORIGINS ??
145
+ [];
146
+
147
+ if (origin !== "" && allowedOrigins.includes(origin)) {
148
+ res.setHeader("Access-Control-Allow-Origin", origin);
149
+ res.setHeader("Vary", "Origin");
150
+
151
+ return;
152
+ }
153
+
154
+ if (allowedOrigins.includes("*")) {
155
+ res.setHeader("Access-Control-Allow-Origin", "*");
156
+ }
157
+ }
158
+
159
+ function authorize(endpointKey, givenKey, Response) {
160
+ const validKey =
161
+ config.CUSTOM_ENDPOINT_KEYS?.[endpointKey] ?? config.GENERAL_API_KEY ?? "";
162
+
163
+ if (validKey === "" || !hashEquals(validKey, givenKey)) {
164
+ Response.error(
165
+ "Unauthorized. X-Api-Key is incorrect or missing",
166
+ STATUS_UNAUTHORIZED,
167
+ );
168
+ }
169
+ }
170
+
171
+ function clientAddress(req) {
172
+ const forwarded = (req.headers["x-forwarded-for"] || "").split(",")[0].trim();
173
+
174
+ return forwarded || req.socket.remoteAddress || "";
175
+ }
176
+
177
+ function readBody(req) {
178
+ return new Promise((resolve) => {
179
+ const chunks = [];
180
+
181
+ req.on("data", (chunk) => chunks.push(chunk));
182
+ req.on("end", () => {
183
+ const raw = Buffer.concat(chunks).toString("utf8");
184
+ let json = {};
185
+
186
+ try {
187
+ json = raw ? JSON.parse(raw) : {};
188
+ } catch (error) {
189
+ json = {};
190
+ }
191
+
192
+ resolve({ raw, json });
193
+ });
194
+ req.on("error", () => resolve({ raw: "", json: {} }));
195
+ });
196
+ }
197
+
198
+ function loadHandlers(file) {
199
+ if (debugMode) {
200
+ delete require.cache[require.resolve(file)];
201
+ }
202
+
203
+ return require(file);
204
+ }
205
+
206
+ async function dispatch(file, context, Response) {
207
+ const handlers = loadHandlers(file);
208
+
209
+ if (typeof handlers === "function") {
210
+ await handlers(context);
211
+
212
+ return;
213
+ }
214
+
215
+ const methodHandler = handlers[context.method.toLowerCase()];
216
+
217
+ if (typeof methodHandler !== "function") {
218
+ Response.error("Method not allowed", STATUS_METHOD_NOT_ALLOWED);
219
+ }
220
+
221
+ await methodHandler(context);
222
+ }
223
+
224
+ async function handleRequest(req, res) {
225
+ const Response = createResponse(res);
226
+
227
+ try {
228
+ const parsedUrl = new URL(req.url, `http://${DEFAULT_HOST}`);
229
+ const segments = parseSegments(parsedUrl.pathname);
230
+
231
+ if (segments.some((segment) => segment === "." || segment === "..")) {
232
+ Response.error("Bad Request", STATUS_BAD_REQUEST);
233
+ }
234
+
235
+ const endpoint = resolveEndpoint(segments);
236
+
237
+ if (endpoint === null) {
238
+ sendErrorPage(res);
239
+
240
+ return;
241
+ }
242
+
243
+ const endpointKey = toEndpointKey(endpoint.file, endpoint.base);
244
+
245
+ applySecurityHeaders(res);
246
+ applyCors(res, endpointKey, req.headers.origin || "");
247
+
248
+ if (req.method === "OPTIONS") {
249
+ res.statusCode = STATUS_NO_CONTENT;
250
+ res.end();
251
+
252
+ return;
253
+ }
254
+
255
+ createRateLimiter(Response, res).check(
256
+ clientAddress(req),
257
+ RATE_LIMIT_REQUESTS,
258
+ RATE_LIMIT_WINDOW_SECONDS,
259
+ );
260
+
261
+ if (endpoint.isProtected) {
262
+ authorize(endpointKey, req.headers["x-api-key"] || "", Response);
263
+ }
264
+
265
+ const body = await readBody(req);
266
+
267
+ await dispatch(
268
+ endpoint.file,
269
+ {
270
+ method: req.method,
271
+ requestParams: endpoint.params,
272
+ query: Object.fromEntries(parsedUrl.searchParams),
273
+ body: body.json,
274
+ rawBody: body.raw,
275
+ headers: req.headers,
276
+ Response,
277
+ Validate: createValidator(Response),
278
+ config,
279
+ CORE_PATH,
280
+ req,
281
+ res,
282
+ },
283
+ Response,
284
+ );
285
+
286
+ if (!res.writableEnded) {
287
+ res.end();
288
+ }
289
+ } catch (error) {
290
+ if (error === HALT) {
291
+ return;
292
+ }
293
+
294
+ try {
295
+ handleException(error, config, Response);
296
+ } catch (inner) {
297
+ if (inner === HALT) {
298
+ return;
299
+ }
300
+
301
+ if (!res.writableEnded) {
302
+ res.statusCode = STATUS_SERVER_ERROR;
303
+ res.end(
304
+ JSON.stringify({
305
+ status: "error",
306
+ message: "Internal server error",
307
+ code: STATUS_SERVER_ERROR,
308
+ }),
309
+ );
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ const host = process.env.HOST || DEFAULT_HOST;
316
+ const port = parseInt(process.env.PORT || DEFAULT_PORT, 10);
317
+
318
+ const server = http.createServer(handleRequest);
319
+
320
+ server.listen(port, host, () => {
321
+ console.log(`Backend started listening on http://${host}:${port}`);
322
+ if (debugMode) {
323
+ console.log(
324
+ 'WARNING: error details are exposed in responses. Set APP_ENV to "production" in config.js before publishing.',
325
+ );
326
+ }
327
+ });
328
+
329
+ module.exports = server;