nibula 1.0.2 → 1.1.1

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/docs/Deploy.md CHANGED
@@ -1,79 +1,85 @@
1
1
  # Server Configuration
2
2
 
3
- Nibula builds a static site into your output folder (`out`). To put it
4
- online you upload that folder to a web server. Which server config you need
5
- depends on **where** you publish.
3
+ Nibula builds a static site into your output folder (`out`). To put it online you
4
+ upload that folder to a web server. Which server config you need depends on
5
+ **where** you publish and **which backend** you chose at scaffold time (PHP or
6
+ Node.js — see `docs/Backend.md`).
6
7
 
7
- ## Where to publish
8
+ The shipped `.htaccess`, `web.config` and `nginx.conf` are written to cover
9
+ **both** backends, so you don't swap config files based on your choice. There is
10
+ one caveat on Apache/IIS, explained at the end.
8
11
 
9
- ### Shared hosting (Apache — e.g. Aruba, most cPanel providers)
12
+ ## Prerequisites
13
+
14
+ **PHP backend:** a server with PHP support (Apache, Nginx + PHP-FPM, or IIS +
15
+ FastCGI) and Composer for the backend dependencies. Works on typical shared
16
+ hosting.
10
17
 
11
- The most common and beginner-friendly option. You rent space on a server that's
12
- already set up, and you just upload your files.
18
+ **Node backend:** Node.js 18+ on the server and the ability to run a
19
+ long-running process i.e. a VPS you control, not typical shared hosting. No PHP
20
+ or Composer required.
21
+
22
+ ## Where to publish
13
23
 
14
- In short: take the **contents of the `out` folder** and upload them to the
15
- hosting's public web root (often called `htdocs`, `public_html`, or `www`).
16
- That's it — the site is live.
24
+ ### Shared hosting (Apache e.g. Aruba, most cPanel providers)
17
25
 
18
- On this kind of hosting the server usually runs **Apache** (or **IIS** on Windows
19
- hosts). This is where `.htaccess` (Apache) or `web.config` (IIS) come in: they're
20
- already inside your `out` folder and get picked up automatically, with no extra
21
- setup. They handle security and routing for you (see below).
26
+ The most common and beginner-friendly option, and the natural home for the
27
+ **PHP** backend (shared hosting can't keep a Node process alive). You rent space
28
+ on a server that's already set up and just upload your files.
22
29
 
23
- > You don't need to understand the details to publish upload `out`, and the
24
- > config files do their job. The sections below explain what they protect.
30
+ Take the **contents of the `out` folder** and upload them to the hosting's public
31
+ web root (often `htdocs`, `public_html`, or `www`). That's it the site is live.
32
+ `.htaccess` (Apache) or `web.config` (IIS) are already inside `out` and get picked
33
+ up automatically, handling security and routing for you.
25
34
 
26
35
  ### Your own server (Nginx — e.g. an Ubuntu VPS)
27
36
 
28
37
  If you rent a bare server (a VPS) and install everything yourself, it typically
29
38
  runs **Nginx** on **Ubuntu Server**. Here nothing is pre-configured: you set up
30
- Nginx, PHP, and the security rules by hand. This is more powerful but requires
31
- knowing what you're doing if this is your path, read up on Nginx server
32
- administration first. The Nginx section below gives you the essential rules to
33
- start from.
39
+ Nginx, the backend, and the security rules by hand. This is the setup for the
40
+ **Node** backend (and also works for PHP + PHP-FPM). The Nginx section below
41
+ gives you the essential rules to start from.
34
42
 
35
43
  ---
36
44
 
37
45
  ## Apache (`.htaccess`)
38
46
 
39
- `.htaccess` files at `src/frontend/` and `src/backend/` are automatically copied
40
- into the build output by Eleventy. No setup required — upload `out` and Apache
41
- reads them on its own.
47
+ `.htaccess` files at `src/frontend/` and `src/backend/` are copied into the build
48
+ output by Eleventy. No setup required — upload `out` and Apache reads them.
42
49
 
43
50
  They cover:
44
51
  - Directory listing disabled
45
52
  - 403 / 404 → `/404.html`
46
53
  - Sensitive files blocked (`web.config`, dotfiles, etc.)
47
54
  - Backend source directory sealed — all access funnels through the front controller
48
- - `/api/*` `backend/_core/index.php`
49
- - HTTPS redirect
55
+ - `/api/*` routing (see the dual-backend note below)
50
56
 
51
- This is the config that makes shared hosting like Aruba work out of the box.
57
+ **Dual backend:** if `mod_proxy` is available, `/api` is reverse-proxied to the
58
+ Node backend (`127.0.0.1:3000`); if not (typical shared hosting), `/api` is
59
+ rewritten to the PHP front controller. To force PHP on a server that *has*
60
+ `mod_proxy`, comment the `mod_proxy` block in `.htaccess`.
52
61
 
53
62
  ## IIS (`web.config`)
54
63
 
55
64
  For Windows-based hosting. `web.config` files at `src/frontend/` and
56
- `src/backend/` are automatically copied into the build output by Eleventy, and
57
- IIS reads them automatically — same idea as `.htaccess`, different server.
65
+ `src/backend/` are copied into the build output by Eleventy and read
66
+ automatically — same idea as `.htaccess`, different server.
58
67
 
59
- Covers the same rules as the Apache configuration above.
68
+ **Dual backend:** the `ApiToNode` rewrite rule (requires the ARR + URL Rewrite
69
+ modules) reverse-proxies `/api` to Node and wins if present. To use PHP instead,
70
+ remove the `ApiToNode` rule; the `ApiToPhp` fallback then rewrites `/api` to the
71
+ PHP front controller.
60
72
 
61
73
  ## Nginx
62
74
 
63
- Used when you run your **own Ubuntu Server** (a VPS). Unlike Apache and IIS,
64
- Nginx does **not** read per-directory config files, so there's nothing automatic
65
- here.
75
+ Used when you run your **own Ubuntu Server** (a VPS). Unlike Apache and IIS, Nginx
76
+ does **not** read per-directory config files, so there's nothing automatic here.
66
77
 
67
78
  **`nginx.conf` in the project root is not a ready-to-use file.** It is a set of
68
- directives that you must take and adapt into a complete Nginx `server { }`
69
- block — the one that defines your domain (`server_name`), ports (`listen`), and
70
- SSL certificates. Paste these rules inside your own server block and adjust
71
- `root` to point to your `out` folder.
72
-
73
- These directives are the **minimum Nginx equivalent of `.htaccess` and
74
- `web.config`**: their job is to not expose dangerous files (server config files,
75
- the backend source directory) and to route the API correctly. Without them, an
76
- Nginx server would serve your site but leave those files reachable.
79
+ directives you adapt into a complete Nginx `server { }` block — the one that
80
+ defines your domain (`server_name`), ports (`listen`), and SSL certificates.
81
+ Paste these rules inside your own server block and set `root` to your `out`
82
+ folder.
77
83
 
78
84
  They provide:
79
85
  - `server_tokens off` — hides the Nginx version
@@ -81,21 +87,104 @@ They provide:
81
87
  - 403 / 404 → `/404.html`
82
88
  - Backend source directory (`/backend/`) sealed with `return 404`
83
89
  - Server config files (`.htaccess`, `web.config`) blocked anywhere in the tree
84
- - `/api/*` routed to the PHP front controller
90
+ - `/api/*` routed to the backend (see below)
85
91
  - Security headers on static responses
86
92
 
87
- > Setting up Nginx correctly (SSL, HTTPS redirect, PHP-FPM) goes beyond these
88
- > rules. If you plan to publish with Nginx, read up on Nginx + PHP-FPM on Ubuntu
89
- > Server first these directives are the security starting point, not a full
90
- > server setup.
93
+ **Dual backend (auto-fallback):** the shipped `nginx.conf` sends `/api` to the
94
+ Node backend on `127.0.0.1:3000` and, if Node is unreachable (502/504), falls
95
+ back automatically to the PHP front controller. So the same config works whether
96
+ you deployed Node or PHP — Nginx is the only server that can do this fallback
97
+ automatically.
98
+
99
+ > Setting up Nginx fully (SSL, HTTPS redirect, PHP-FPM and/or the Node service)
100
+ > goes beyond these rules. These directives are the security starting point, not
101
+ > a complete server setup.
91
102
 
92
103
  ### PHP-FPM socket
93
104
 
94
- The socket path in the `/api/` rule targets RHEL / Fedora by default. On Ubuntu
95
- the socket name includes the PHP version — check with `ls /run/php/` and adjust:
105
+ When using the PHP path, the socket in the `/api` fallback targets Debian/Ubuntu
106
+ by default. Check with `ls /run/php/` and adjust:
96
107
 
97
108
  | Distro | Path |
98
109
  |---|---|
99
- | RHEL / Fedora | `unix:/run/php-fpm/php-fpm.sock` |
100
110
  | Debian / Ubuntu | `unix:/run/php/php8.3-fpm.sock` |
101
- | TCP fallback | `127.0.0.1:9000` |
111
+ | RHEL / Fedora | `unix:/run/php-fpm/php-fpm.sock` |
112
+ | TCP fallback | `127.0.0.1:9000` |
113
+
114
+ ---
115
+
116
+ ## Starting the Node backend on the server
117
+
118
+ Unlike PHP, the Node backend is **not** executed by the web server per request —
119
+ it is a process you start and keep alive. After uploading your built `out` folder:
120
+
121
+ 1. Install the backend's runtime deps (only needed if an endpoint uses the DB):
122
+ ```bash
123
+ cd /var/www/your-site/out/backend
124
+ npm install
125
+ ```
126
+
127
+ 2. Make sure `config.js` exists in `out/backend/` (copy `example.config.js` to
128
+ `config.js` if missing) and fill in your values.
129
+
130
+ 3. Start it — quick test first:
131
+ ```bash
132
+ node _core/index.js
133
+ # -> [backend-node] listening on http://127.0.0.1:3000 (env: production)
134
+ ```
135
+ Then, from the server, confirm it answers:
136
+ ```bash
137
+ curl http://127.0.0.1:3000/api/example-public
138
+ ```
139
+
140
+ > Running `node _core/index.js` directly ties the process to your SSH session —
141
+ > close the terminal and the backend dies. The recommended way to try it out
142
+ > without staying stuck there is **`screen`** (a detachable terminal):
143
+ > ```bash
144
+ > screen -S backend # open a named session
145
+ > node _core/index.js # start the backend inside it
146
+ > # press Ctrl+A then D to detach — the backend keeps running
147
+ > # reattach later with: screen -r backend
148
+ > ```
149
+ > `screen` is perfect for testing and small setups. For a real production
150
+ > deployment that also restarts on crash/reboot, prefer **systemd** (step 4).
151
+
152
+ 4. To keep it running after logout, use **systemd**. An example unit ships at
153
+ `backend/backend-node.service.example` — copy it to
154
+ `/etc/systemd/system/backend-node.service`, adjust `WorkingDirectory` and the
155
+ `node` path (`which node`), then:
156
+ ```bash
157
+ sudo systemctl daemon-reload
158
+ sudo systemctl enable --now backend-node
159
+ sudo systemctl status backend-node
160
+ ```
161
+ (`pm2` also works: `pm2 start _core/index.js --name backend-node`.)
162
+
163
+ The web server then reverse-proxies `/api` to this process; with the shipped
164
+ `nginx.conf`, if Node is down Nginx falls back to PHP automatically.
165
+
166
+ ### Environment variables
167
+
168
+ | Variable | Default | Purpose |
169
+ |---|---|---|
170
+ | `PORT` | `3000` | Port Node listens on (match your proxy target) |
171
+ | `HOST` | `127.0.0.1` | Bind address (keep it local; the web server is the public face) |
172
+ | `APP_ENV` | `production` | `production` hides error details; anything else = verbose 500s |
173
+ | `DOCUMENT_ROOT` | auto (`out/`) | Where `404.html` lives; auto-detected as the folder above `backend/` |
174
+
175
+ ---
176
+
177
+ ## The one caveat on Apache and IIS
178
+
179
+ Nginx can health-check the Node upstream and fall back to PHP automatically.
180
+ Apache (`.htaccess`) and IIS (`web.config`) **cannot** — a per-directory config
181
+ can't know whether Node is up. So there the routing is decided by configuration,
182
+ not at runtime:
183
+
184
+ - **Apache:** `mod_proxy` present → Node; absent → PHP. Comment the `mod_proxy`
185
+ block to force PHP on a proxy-enabled server.
186
+ - **IIS:** `ApiToNode` rule present (needs ARR) → Node; remove it to use the
187
+ `ApiToPhp` fallback.
188
+
189
+ Since a given deployment runs only one backend, this is a one-time setup, not a
190
+ per-request concern.
package/nginx.conf CHANGED
@@ -1,10 +1,22 @@
1
+ # Example nginx directives for a Nibula site — covers BOTH backends.
2
+ #
3
+ # /api is served by Node (index.js) if it is running on 127.0.0.1:3000; if Node
4
+ # is unreachable, nginx falls back to the PHP front controller automatically.
5
+ # So the same config works whether you deployed the Node backend or the PHP one:
6
+ # - Node backend running -> Node answers /api
7
+ # - only PHP deployed -> Node is down -> falls back to PHP-FPM
8
+ #
9
+ # This is NOT a ready-to-use file: paste these directives inside your own
10
+ # server { } block (the one with server_name, listen and SSL) and set `root`
11
+ # to your `out` folder.
12
+
1
13
  # Hide the nginx version from response headers
2
14
  server_tokens off;
3
15
 
4
16
  # Force HTTPS on future requests (applies site-wide, including /api)
5
17
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
6
18
 
7
- root /var/www/html/out;
19
+ root /var/www/your-site/out;
8
20
  index index.html;
9
21
 
10
22
  # Disable directory listing
@@ -15,24 +27,40 @@ location = /404.html {
15
27
  internal;
16
28
  }
17
29
 
18
- # Never expose the backend source directory
30
+ # Never expose the backend source directory over HTTP.
19
31
  location ^~ /backend/ {
20
32
  return 404;
21
33
  }
22
34
 
23
35
  # Block server config files anywhere in the tree (.htaccess, web.config).
24
- # return 404 is served internally as /404.html via error_page above.
25
36
  location ~* (^|/)(\.htaccess|web\.config)$ {
26
37
  return 404;
27
38
  }
28
39
 
29
- # Route all /api/ requests to the PHP front controller.
30
- # Security headers for API responses are set by the front controller (PHP).
40
+ # --- /api: Node primary, PHP fallback ---------------------------------------
41
+ # Try the Node backend first. If it is down (502/504), retry through the PHP
42
+ # front controller. Security headers for API responses are set by the backend.
31
43
  location ^~ /api/ {
32
- try_files $uri =404;
33
- fastcgi_pass unix:/run/php-fpm/php-fpm.sock;
34
- fastcgi_param SCRIPT_FILENAME $document_root/backend/_core/index.php;
44
+ proxy_pass http://127.0.0.1:3000;
45
+ proxy_http_version 1.1;
46
+ proxy_set_header Host $host;
47
+ proxy_set_header X-Real-IP $remote_addr;
48
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
49
+ proxy_set_header X-Forwarded-Proto $scheme;
50
+
51
+ proxy_intercept_errors on;
52
+ error_page 502 504 = @php_backend;
53
+ }
54
+
55
+ location @php_backend {
35
56
  include fastcgi_params;
57
+ fastcgi_param SCRIPT_FILENAME $document_root/backend/_core/index.php;
58
+
59
+ # Adjust the PHP-FPM socket to your distro:
60
+ # Debian / Ubuntu : unix:/run/php/php8.3-fpm.sock
61
+ # RHEL / Fedora : unix:/run/php-fpm/php-fpm.sock
62
+ # TCP fallback : 127.0.0.1:9000
63
+ fastcgi_pass unix:/run/php/php8.3-fpm.sock;
36
64
  }
37
65
 
38
66
  # Serve static files, fall back to the 404 page.
@@ -44,4 +72,4 @@ location / {
44
72
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
45
73
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
46
74
  try_files $uri $uri/ /404.html;
47
- }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nibula",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
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
  "static-site-generator",
@@ -0,0 +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;
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Mirror of src/backend/_core/init.php
5
+ *
6
+ * init.php did three things:
7
+ * 1. registered a global exception handler that turns any throwable into a
8
+ * 500 JSON response (verbose in debug, generic in production);
9
+ * 2. registered an error handler that promoted PHP warnings to exceptions;
10
+ * 3. loaded config.php.
11
+ *
12
+ * In Node there is no per-request global handler, so the exception-to-500
13
+ * translation lives in the front controller (index.js), which calls
14
+ * handleException() below. Here we expose config loading and that helper.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ /**
21
+ * Load configuration. dirname(__dirname) in PHP pointed at the folder holding
22
+ * config.php (the backend root). Here that is the parent of _core.
23
+ * If config.js is missing (e.g. fresh clone) we fall back to example.config.js,
24
+ * mirroring the "copy example.config to config" guidance.
25
+ */
26
+ function loadConfig() {
27
+ const backendRoot = path.join(__dirname, '..');
28
+ const configPath = path.join(backendRoot, 'config.js');
29
+ const examplePath = path.join(backendRoot, 'example.config.js');
30
+
31
+ if (fs.existsSync(configPath)) {
32
+ return require(configPath);
33
+ }
34
+ return require(examplePath);
35
+ }
36
+
37
+ /**
38
+ * Turn any thrown error into a 500 response, mirroring set_exception_handler.
39
+ * @param {Error} exception
40
+ * @param {object} config
41
+ * @param {object} Response request-bound helper
42
+ */
43
+ function handleException(exception, config, Response) {
44
+ const isDebug = (config.APP_ENV || 'production') !== 'production';
45
+ Response.error(
46
+ isDebug ? exception.message : 'Internal server error',
47
+ 500,
48
+ isDebug ? { stack: exception.stack } : null
49
+ );
50
+ }
51
+
52
+ module.exports = { loadConfig, handleException };